From 6441d6af00a12761188515ed37f5763e38a30d09 Mon Sep 17 00:00:00 2001
From: Sag
Date: Wed, 6 May 2026 07:31:40 +0200
Subject: [PATCH 1/8] Added paid welcome email for existing members redeeming a
gift (#27659)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
closes https://linear.app/ghost/issue/BER-3541
- Existing free members who redeem a gift subscription now receive the
paid welcome email — previously only new gift signups got it.
- Centralised the paid welcome email enqueue inside
`gift-service.redeem()` so new and existing-member redemptions share one
code path.
- Refactored existing welcome email paths to use the new shared
`enqueueWelcomeEmailRun()` method
---------
Co-authored-by: Troy Ciesco
---
.../server/services/gifts/gift-service.ts | 5 +
.../repositories/member-repository.js | 133 +++++++++---------
.../members/gift-subscriptions.test.js | 69 ++++++++-
.../services/gifts/gift-service.test.ts | 68 ++++++++-
.../repositories/member-repository.test.js | 77 ----------
5 files changed, 203 insertions(+), 149 deletions(-)
diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts
index 5e261b873f4..7130e774926 100644
--- a/ghost/core/core/server/services/gifts/gift-service.ts
+++ b/ghost/core/core/server/services/gifts/gift-service.ts
@@ -5,6 +5,7 @@ import {Gift} from './gift';
import type {GiftRepository} from './gift-repository';
import tpl from '@tryghost/tpl';
import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants';
+import {MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY;
@@ -37,6 +38,7 @@ interface MemberModel {
interface MemberRepository {
get(filter: Record, options?: Record): Promise;
update(data: Record, options?: Record): Promise;
+ enqueueWelcomeEmailRun(memberId: string, slug: string, options?: Record): Promise;
}
type Tier = {
@@ -325,6 +327,9 @@ export class GiftService {
await this.deps.giftRepository.update(redeemed, {transacting});
+ // Gift members receive the paid welcome email, as they receive access to paid content
+ await this.deps.memberRepository.enqueueWelcomeEmailRun(memberId, MEMBER_WELCOME_EMAIL_SLUGS.paid, {transacting});
+
return {redeemed, member};
};
diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
index 9551df91055..1388400decf 100644
--- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
+++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
@@ -173,6 +173,57 @@ module.exports = class MemberRepository {
return nickname && nickname.toLowerCase() === 'complimentary';
}
+ /**
+ * Looks up the active welcome email automation for the given slug and enqueues a
+ * `WelcomeEmailAutomationRun` for the member. Dispatches `StartAutomationsPollEvent`
+ * so the poll picks it up. Returns the created run, or null if there is no active
+ * automation/email for that slug.
+ *
+ * Callers are responsible for any eligibility gating (member status, source, etc.)
+ * before calling this — this helper just looks up + inserts + dispatches. Pass
+ * `options.transacting` to run the insert inside an existing transaction; the
+ * dispatch is automatically deferred until that transaction commits.
+ *
+ * @param {string} memberId
+ * @param {string} slug automation slug, see MEMBER_WELCOME_EMAIL_SLUGS
+ * @param {object} [options] bookshelf options (transacting, context, etc.)
+ */
+ async enqueueWelcomeEmailRun(memberId, slug, options = {}) {
+ if (!this._WelcomeEmailAutomation || !this._WelcomeEmailAutomationRun) {
+ return null;
+ }
+
+ const automation = await this._WelcomeEmailAutomation.findOne(
+ {slug},
+ {...options, withRelated: ['welcomeEmailAutomatedEmail']}
+ );
+ const email = automation?.related('welcomeEmailAutomatedEmail');
+ const isActive = Boolean(
+ automation &&
+ email &&
+ email.get('lexical') &&
+ automation.get('status') === 'active'
+ );
+
+ if (!isActive) {
+ return null;
+ }
+
+ const run = await this._WelcomeEmailAutomationRun.add({
+ welcome_email_automation_id: automation.id,
+ member_id: memberId,
+ next_welcome_email_automated_email_id: email.id,
+ ready_at: new Date(),
+ step_started_at: null,
+ step_attempts: 0,
+ exit_reason: null
+ }, options);
+
+ this.dispatchEvent(StartAutomationsPollEvent.create(), options);
+
+ return run;
+ }
+
/**
* Maps the framework context to members_*.source table record value
* @param {Object} context instance of ghost framework context object
@@ -382,50 +433,15 @@ module.exports = class MemberRepository {
let member;
const isFreeSignup = !stripeCustomer && memberData.status === 'free';
- const isGiftSignup = !stripeCustomer && memberData.status === 'gift';
- let welcomeEmailToEnqueue = null;
-
- if (this._WelcomeEmailAutomation && WELCOME_EMAIL_SOURCES.includes(source)) {
- const getActiveWelcomeEmailToEnqueue = async (slug) => {
- const automation = await this._WelcomeEmailAutomation.findOne(
- {slug},
- {...options, withRelated: ['welcomeEmailAutomatedEmail']}
- );
- const email = automation?.related('welcomeEmailAutomatedEmail');
- const isActive = Boolean(
- automation &&
- email &&
- email.get('lexical') &&
- automation.get('status') === 'active'
- );
-
- return isActive ? {automation, email} : null;
- };
-
- if (isFreeSignup) {
- welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.free);
- } else if (isGiftSignup) {
- // As gift members get access to a paid tier, they receive the paid welcome email
- welcomeEmailToEnqueue = await getActiveWelcomeEmailToEnqueue(MEMBER_WELCOME_EMAIL_SLUGS.paid);
- }
- }
- if (welcomeEmailToEnqueue) {
+ if (isFreeSignup && WELCOME_EMAIL_SOURCES.includes(source)) {
const runMemberCreation = async (transacting) => {
const newMember = await this._Member.add({
...memberData,
labels
}, {...memberAddOptions, transacting});
- await this._WelcomeEmailAutomationRun.add({
- welcome_email_automation_id: welcomeEmailToEnqueue.automation.id,
- member_id: newMember.id,
- next_welcome_email_automated_email_id: welcomeEmailToEnqueue.email.id,
- ready_at: new Date(),
- step_started_at: null,
- step_attempts: 0,
- exit_reason: null
- }, {transacting});
+ await this.enqueueWelcomeEmailRun(newMember.id, MEMBER_WELCOME_EMAIL_SLUGS.free, {transacting});
return newMember;
};
@@ -435,8 +451,6 @@ module.exports = class MemberRepository {
} else {
member = await this._Member.transaction(runMemberCreation);
}
-
- this.dispatchEvent(StartAutomationsPollEvent.create(), memberAddOptions);
} else {
member = await this._Member.add({
...memberData,
@@ -1516,38 +1530,17 @@ module.exports = class MemberRepository {
const context = options?.context || {};
const source = this._resolveContextSource(context);
- const shouldSendPaidWelcomeEmail = WELCOME_EMAIL_SOURCES.includes(source);
- let isPaidWelcomeEmailActive = false;
- let paidWelcomeAutomation = null;
- let paidWelcomeEmail = null;
- if (shouldSendPaidWelcomeEmail && this._WelcomeEmailAutomation) {
- paidWelcomeAutomation = await this._WelcomeEmailAutomation.findOne(
- {slug: MEMBER_WELCOME_EMAIL_SLUGS.paid},
- {...options, withRelated: ['welcomeEmailAutomatedEmail']}
- );
- paidWelcomeEmail = paidWelcomeAutomation?.related('welcomeEmailAutomatedEmail');
- isPaidWelcomeEmailActive = Boolean(
- paidWelcomeAutomation &&
- paidWelcomeEmail &&
- paidWelcomeEmail.get('lexical') &&
- paidWelcomeAutomation.get('status') === 'active'
- );
- }
- // Send paid welcome email if:
- // 1. The paid welcome email is active
+
+ // Enqueue paid welcome email if:
+ // 1. The source is allowed to send welcome emails
// 2. The member status changed to 'paid'
- // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on signup
- if (updatedMember.get('status') === 'paid' && updatedMember._previousAttributes.status !== 'gift' && isPaidWelcomeEmailActive) {
- await this._WelcomeEmailAutomationRun.add({
- welcome_email_automation_id: paidWelcomeAutomation.id,
- member_id: memberModel.id,
- next_welcome_email_automated_email_id: paidWelcomeEmail.id,
- ready_at: new Date(),
- step_started_at: null,
- step_attempts: 0,
- exit_reason: null
- }, options);
- this.dispatchEvent(StartAutomationsPollEvent.create(), options);
+ // 3. The previous status wasn't 'gift', as gift members already received the paid welcome email on redemption
+ if (
+ WELCOME_EMAIL_SOURCES.includes(source) &&
+ updatedMember.get('status') === 'paid' &&
+ updatedMember._previousAttributes.status !== 'gift'
+ ) {
+ await this.enqueueWelcomeEmailRun(memberModel.id, MEMBER_WELCOME_EMAIL_SLUGS.paid, options);
}
}
}
diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js
index c62b43163c0..cd6c9cae188 100644
--- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js
+++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js
@@ -873,7 +873,7 @@ describe('Gift Subscriptions', function () {
const email = 'gift-existing-member@test.com';
// Create member first
- await models.Member.add({email, name: 'Existing Member', email_disabled: false});
+ const existingMember = await models.Member.add({email, name: 'Existing Member', email_disabled: false});
await DomainEvents.allSettled();
const gift = await createGift();
@@ -881,13 +881,54 @@ describe('Gift Subscriptions', function () {
const redirectUrl = new URL(urlUtils.getSiteUrl());
redirectUrl.hash = '#/portal/account?giftRedemption=true';
+ let freeWelcomeAutomation;
+ let paidWelcomeAutomation;
+
try {
+ // Set up both free and paid welcome email automations to verify gift
+ // redemption picks the paid welcome email (not the free one) — same as
+ // the new-member case above.
+ const emailDesignSetting = await models.EmailDesignSetting.findOne(
+ {slug: 'default-automated-email'},
+ {require: true}
+ );
+ freeWelcomeAutomation = await models.WelcomeEmailAutomation.add({
+ name: 'Free welcome email',
+ slug: 'member-welcome-email-free',
+ status: 'active'
+ });
+ await models.WelcomeEmailAutomatedEmail.add({
+ welcome_email_automation_id: freeWelcomeAutomation.id,
+ delay_days: 0,
+ subject: 'Welcome to the site!',
+ lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome'}]}]}}),
+ email_design_setting_id: emailDesignSetting.id
+ });
+ paidWelcomeAutomation = await models.WelcomeEmailAutomation.add({
+ name: 'Paid welcome email',
+ slug: 'member-welcome-email-paid',
+ status: 'active'
+ });
+ await models.WelcomeEmailAutomatedEmail.add({
+ welcome_email_automation_id: paidWelcomeAutomation.id,
+ delay_days: 0,
+ subject: 'Welcome to the paid tier!',
+ lexical: JSON.stringify({root: {children: [{type: 'paragraph', children: [{text: 'Welcome paid'}]}]}}),
+ email_design_setting_id: emailDesignSetting.id
+ });
+
await models.Product.edit({
welcome_page_url: ''
}, {
id: paidProduct.id
});
+ // The existing free member shouldn't have any welcome runs yet
+ const runsBefore = await models.WelcomeEmailAutomationRun.findAll({
+ filter: `member_id:'${existingMember.id}'`
+ });
+ assert.equal(runsBefore.length, 0, 'Existing free member should have no welcome email runs before redemption');
+
const magicLink = await membersService.api.getMagicLink(email, 'subscribe', {
giftToken: gift.get('token')
});
@@ -916,6 +957,19 @@ describe('Gift Subscriptions', function () {
assert.ok(gift.get('redeemed_at'));
assert.ok(gift.get('consumes_at'));
+ // Verify the paid welcome automation enqueued a run for this member,
+ // and that the free welcome automation did NOT (gift redemption
+ // delivers the paid welcome email regardless of pre-redemption status).
+ const welcomeRuns = await models.WelcomeEmailAutomationRun.findAll({
+ filter: `member_id:'${member.id}'`
+ });
+ assert.equal(welcomeRuns.length, 1, 'Should enqueue exactly one welcome email automation run for an existing free member redeeming a gift');
+ assert.equal(
+ welcomeRuns.models[0].get('welcome_email_automation_id'),
+ paidWelcomeAutomation.id,
+ 'Should enqueue the paid welcome email automation, not the free one'
+ );
+
// Verify gift subscription started staff notification was sent
mockManager.assert.sentEmail({
subject: /paid subscription started/i,
@@ -933,6 +987,19 @@ describe('Gift Subscriptions', function () {
}, {
id: paidProduct.id
});
+
+ for (const automation of [freeWelcomeAutomation, paidWelcomeAutomation]) {
+ if (!automation) {
+ continue;
+ }
+ const runs = await models.WelcomeEmailAutomationRun.findAll({
+ filter: `welcome_email_automation_id:'${automation.id}'`
+ });
+ for (const run of runs.models) {
+ await models.WelcomeEmailAutomationRun.destroy({id: run.id});
+ }
+ await models.WelcomeEmailAutomation.destroy({id: automation.id});
+ }
}
});
});
diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
index fb06ab874d0..f3b53505e07 100644
--- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
+++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts
@@ -47,6 +47,7 @@ describe('GiftService', function () {
let memberRepository: {
get: sinon.SinonStub;
update: sinon.SinonStub;
+ enqueueWelcomeEmailRun: sinon.SinonStub;
};
let staffServiceEmails: {
notifyGiftReceived: sinon.SinonStub;
@@ -97,7 +98,8 @@ describe('GiftService', function () {
memberGet.withArgs('status').returns('free');
return Promise.resolve({id: 'member_1', get: memberGet});
}),
- update: sinon.stub().resolves(undefined)
+ update: sinon.stub().resolves(undefined),
+ enqueueWelcomeEmailRun: sinon.stub().resolves(undefined)
};
staffServiceEmails = {
notifyGiftReceived: sinon.stub(),
@@ -1289,6 +1291,70 @@ describe('GiftService', function () {
sinon.assert.notCalled(giftRepository.update);
sinon.assert.notCalled(staffServiceEmails.notifyGiftSubscriptionStarted);
});
+
+ it('enqueues the paid welcome email run for a new gift signup', async function () {
+ const gift = buildGift();
+ const memberGet = sinon.stub();
+ memberGet.withArgs('status').returns('gift');
+ memberGet.withArgs('name').returns('Member Name');
+ memberGet.withArgs('email').returns('member@example.com');
+
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves({id: 'member_1', get: memberGet});
+
+ const service = createService();
+ await service.redeem('gift-token', 'member_1', {newMember: true});
+
+ sinon.assert.calledOnceWithExactly(
+ memberRepository.enqueueWelcomeEmailRun,
+ 'member_1',
+ 'member-welcome-email-paid',
+ {transacting: 'trx'}
+ );
+ });
+
+ it('enqueues the paid welcome email run when an existing free member redeems a gift', async function () {
+ const gift = buildGift();
+ const memberGet = sinon.stub();
+ memberGet.withArgs('status').returns('free');
+ memberGet.withArgs('name').returns('Member Name');
+ memberGet.withArgs('email').returns('member@example.com');
+
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves({id: 'member_1', get: memberGet});
+
+ const service = createService();
+ await service.redeem('gift-token', 'member_1');
+
+ sinon.assert.calledOnceWithExactly(
+ memberRepository.enqueueWelcomeEmailRun,
+ 'member_1',
+ 'member-welcome-email-paid',
+ {transacting: 'trx'}
+ );
+ });
+
+ it('passes the external transaction through to the welcome email enqueue', async function () {
+ const gift = buildGift();
+ const memberGet = sinon.stub();
+ memberGet.withArgs('status').returns('free');
+ memberGet.withArgs('name').returns('Member Name');
+ memberGet.withArgs('email').returns('member@example.com');
+
+ giftRepository.getByToken.resolves(gift);
+ memberRepository.get.resolves({id: 'member_1', get: memberGet});
+
+ const service = createService();
+ const externalTrx = {executionPromise: Promise.resolve()};
+ await service.redeem('gift-token', 'member_1', {transacting: externalTrx});
+
+ sinon.assert.calledOnceWithExactly(
+ memberRepository.enqueueWelcomeEmailRun,
+ 'member_1',
+ 'member-welcome-email-paid',
+ {transacting: externalTrx}
+ );
+ });
});
describe('scheduleReminder (via redeem)', function () {
diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
index edc9be182ae..531e4d1c855 100644
--- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
+++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js
@@ -1563,83 +1563,6 @@ describe('MemberRepository', function () {
assert.equal(runCall.exit_reason, null);
});
- it('creates automation run for gift member signup (paid welcome email)', async function () {
- // Override stub with paid welcome email
- WelcomeEmailAutomation.findOne = sinon.stub().resolves({
- id: 'automation_id_paid',
- get: sinon.stub().callsFake((key) => {
- const data = {status: 'active'};
- return data[key];
- }),
- related: sinon.stub().callsFake((relation) => {
- assert.equal(relation, 'welcomeEmailAutomatedEmail');
- return {
- id: 'automated_email_id_paid',
- get: sinon.stub().callsFake((key) => {
- const data = {lexical: '{"root":{}}'};
- return data[key];
- })
- };
- })
- });
-
- const repo = new MemberRepository({
- Member,
- Outbox,
- WelcomeEmailAutomationRun,
- MemberStatusEvent,
- MemberSubscribeEventModel: MemberSubscribeEvent,
- newslettersService,
- WelcomeEmailAutomation,
- OfferRedemption: mockOfferRedemption
- });
-
- await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {});
-
- sinon.assert.calledOnce(WelcomeEmailAutomation.findOne);
- assert.equal(WelcomeEmailAutomation.findOne.firstCall.args[0].slug, 'member-welcome-email-paid');
-
- sinon.assert.calledOnce(WelcomeEmailAutomationRun.add);
- const runCall = WelcomeEmailAutomationRun.add.firstCall.args[0];
- assert.equal(runCall.welcome_email_automation_id, 'automation_id_paid');
- assert.equal(runCall.member_id, 'member_id_123');
- assert.equal(runCall.next_welcome_email_automated_email_id, 'automated_email_id_paid');
- });
-
- it('does NOT create automation run for a gift signup when the paid welcome email is inactive', async function () {
- // Override stub with inactive paid welcome email
- WelcomeEmailAutomation.findOne = sinon.stub().resolves({
- id: 'automation_id_paid',
- get: sinon.stub().callsFake((key) => {
- const data = {status: 'inactive'};
- return data[key];
- }),
- related: sinon.stub().callsFake(() => ({
- id: 'automated_email_id_paid',
- get: sinon.stub().callsFake((key) => {
- const data = {lexical: '{"root":{}}'};
- return data[key];
- })
- }))
- });
-
- const repo = new MemberRepository({
- Member,
- Outbox,
- WelcomeEmailAutomationRun,
- MemberStatusEvent,
- MemberSubscribeEventModel: MemberSubscribeEvent,
- newslettersService,
- WelcomeEmailAutomation,
- OfferRedemption: mockOfferRedemption
- });
-
- await repo.create({email: 'test@example.com', name: 'Test Member', status: 'gift'}, {});
-
- sinon.assert.notCalled(WelcomeEmailAutomationRun.add);
- sinon.assert.notCalled(Member.transaction);
- });
-
it('does not create automation run for disallowed sources', async function () {
const repo = new MemberRepository({
Member,
From 5b7a4e1a88d9d0512b1afb82db7b942ab12c9aec Mon Sep 17 00:00:00 2001
From: Jannis Fedoruk-Betschki
Date: Tue, 5 May 2026 19:45:38 +0200
Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=8E=A8=20Expanded=20file=20upload=20a?=
=?UTF-8?q?llowlist=20with=20non-executable=20formats?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ref https://github.com/TryGhost/Ghost/pull/26869
- the allowlist introduced in #26869 protects against executable
content on the shared CDN domain, but rejects many legitimate
non-executable formats that worked before (camera RAW files, common
archive formats, modern image/video containers, 3D/CAD formats,
etc.)
- the additions are safe because the storage adapter's
`getStorageContentType()` already falls back to
`application/octet-stream` for any extension whose MIME type is not
on the browser-renderable allowlist, forcing a download rather than
rendering or executing the file
- new extensions cover photography (.np3, .nef, .cr2, .cr3, .arw,
.raf, .orf, .rw2, .dng, .tif, .tiff, .bmp, .heic, .heif, .avif),
audio (.flac, .aac, .aif, .aiff, .opus, .mid, .midi),
video (.webm, .mkv, .avi, .m4v),
archives (.7z, .rar, .gz, .tgz, .tar, .bz2),
3D/design (.stl, .obj, .glb, .gltf, .fbx, .blend, .ai, .eps, .xcf),
fonts (.ttf, .eot),
data/docs (.toml, .tsv, .geojson, .vcf, .numbers, .odp, .ppt, .fb2)
- sorted the array alphabetically for easier future maintenance
- updated the importer glob test to match the expanded list
---
ghost/core/core/shared/config/overrides.json | 74 ++++++++++++++++---
.../unit/server/data/importer/index.test.js | 4 +-
2 files changed, 65 insertions(+), 13 deletions(-)
diff --git a/ghost/core/core/shared/config/overrides.json b/ghost/core/core/shared/config/overrides.json
index fdc3d03fe9c..cd23b412f40 100644
--- a/ghost/core/core/shared/config/overrides.json
+++ b/ghost/core/core/shared/config/overrides.json
@@ -44,53 +44,105 @@
},
"files": {
"extensions": [
- ".pdf",
- ".json",
- ".jsonld",
- ".ods",
- ".odt",
- ".pptx",
- ".rtf",
- ".txt",
- ".xls",
- ".xlsx",
- ".xml",
+ ".7z",
+ ".aac",
+ ".ai",
+ ".aif",
+ ".aiff",
".apkg",
+ ".arw",
+ ".avif",
+ ".avi",
+ ".blend",
+ ".bmp",
+ ".bz2",
+ ".cr2",
+ ".cr3",
".css",
".csv",
+ ".dng",
".doc",
".docx",
+ ".eot",
".epub",
+ ".eps",
+ ".fb2",
+ ".fbx",
+ ".flac",
+ ".geojson",
".gif",
+ ".glb",
+ ".gltf",
".gpx",
+ ".gz",
+ ".heic",
+ ".heif",
".html",
".ics",
".ipynb",
".jpeg",
".jpg",
".js",
+ ".json",
+ ".jsonld",
".key",
".kml",
".m4a",
+ ".m4v",
".md",
+ ".mid",
+ ".midi",
+ ".mkv",
".mobi",
".mov",
".mp3",
".mp4",
+ ".nef",
+ ".np3",
+ ".numbers",
+ ".obj",
+ ".odp",
+ ".ods",
+ ".odt",
+ ".ogv",
+ ".opus",
+ ".orf",
".otf",
".pages",
".paprikarecipes",
+ ".pdf",
".png",
+ ".ppt",
+ ".pptx",
".psd",
".py",
+ ".raf",
+ ".rar",
+ ".rtf",
+ ".rw2",
".skp",
+ ".stl",
".svg",
+ ".tar",
+ ".tgz",
+ ".tif",
+ ".tiff",
+ ".toml",
+ ".tsv",
+ ".ttf",
+ ".txt",
+ ".vcf",
".wav",
+ ".webm",
".webp",
".woff",
".woff2",
+ ".xcf",
+ ".xls",
".xlsb",
".xlsm",
+ ".xlsx",
+ ".xml",
".yaml",
".zip"
],
diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js
index 76c444946e9..b61d82c7a13 100644
--- a/ghost/core/test/unit/server/data/importer/index.test.js
+++ b/ghost/core/test/unit/server/data/importer/index.test.js
@@ -42,7 +42,7 @@ describe('Importer', function () {
it('gets the correct extensions', function () {
assert(Array.isArray(ImportManager.getExtensions()));
- assert.equal(ImportManager.getExtensions().length, 55);
+ assert.equal(ImportManager.getExtensions().length, 105);
assert(ImportManager.getExtensions().includes('.csv'));
assert(ImportManager.getExtensions().includes('.json'));
assert(ImportManager.getExtensions().includes('.zip'));
@@ -108,7 +108,7 @@ describe('Importer', function () {
});
it('globs extensions correctly', function () {
- const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.pdf|.json|.jsonld|.ods|.odt|.pptx|.rtf|.txt|.xls|.xlsx|.xml|.apkg|.css|.csv|.doc|.docx|.epub|.gpx|.html|.ics|.ipynb|.js|.key|.kml|.md|.mobi|.mov|.otf|.pages|.paprikarecipes|.psd|.py|.skp|.woff|.woff2|.xlsb|.xlsm|.yaml|.zip|.markdown)';
+ const extGlob = '+(.jpg|.jpeg|.gif|.png|.svg|.svgz|.ico|.webp|.mp4|.webm|.ogv|.mp3|.wav|.ogg|.m4a|.7z|.aac|.ai|.aif|.aiff|.apkg|.arw|.avif|.avi|.blend|.bmp|.bz2|.cr2|.cr3|.css|.csv|.dng|.doc|.docx|.eot|.epub|.eps|.fb2|.fbx|.flac|.geojson|.glb|.gltf|.gpx|.gz|.heic|.heif|.html|.ics|.ipynb|.js|.json|.jsonld|.key|.kml|.m4v|.md|.mid|.midi|.mkv|.mobi|.mov|.nef|.np3|.numbers|.obj|.odp|.ods|.odt|.opus|.orf|.otf|.pages|.paprikarecipes|.pdf|.ppt|.pptx|.psd|.py|.raf|.rar|.rtf|.rw2|.skp|.stl|.tar|.tgz|.tif|.tiff|.toml|.tsv|.ttf|.txt|.vcf|.woff|.woff2|.xcf|.xls|.xlsb|.xlsm|.xlsx|.xml|.yaml|.zip|.markdown)';
assert.equal(ImportManager.getGlobPattern(ImportManager.getExtensions()), extGlob);
assert.equal(ImportManager.getGlobPattern(ImportManager.getDirectories()), '+(images|content|media|files)');
assert.equal(ImportManager.getGlobPattern(JSONHandler.extensions), '+(.json)');
From 42dba3298f8f6d36609eb1f109855ebe032c307d Mon Sep 17 00:00:00 2001
From: Kevin Ansfield
Date: Wed, 6 May 2026 09:06:06 +0100
Subject: [PATCH 3/8] Fixed missing onboarding flow in React admin shell
(#27625)
ref https://linear.app/ghost/issue/BER-3587/
The onboarding flow was lost during the Ember->React migration of the
Admin shell. This recreates it in the React admin app on a dedicated
route, while keeping Analytics as the return destination until the
checklist is skipped or completed.
Because onboarding has been missing for several months, existing owners
may have stale onboarding preferences. This PR records
`onboarding.startedAt` when `/setup/done` starts the checklist and only
shows `started` checklists whose timestamp is on or after the fixed 30
April 2026 cutoff. Missing or older timestamps are dismissed so
long-time product users are not suddenly forced into onboarding.
## Summary
- adds a dedicated React admin onboarding route at `/setup/onboarding`
- stores checklist state through the user preferences hooks while
keeping the legacy accessibility storage detail hidden
- redirects active onboarding users away from Analytics until they skip
or complete the checklist
- updates Ember `/setup/done` so new owner setup is the only path that
starts onboarding
- records `onboarding.startedAt` and dismisses missing/legacy
`startedAt` values before the fixed 30 April 2026 cutoff so existing
long-time owners are not forced into onboarding
- shares the Shade `ShareModal` base between the onboarding publication
share dialog and `PostShareModal`
- aligns publication and post share options, including X, Threads,
Facebook, LinkedIn, and copy link
- adds Shade Storybook coverage for the shared share modal and fixes
Storybook dark-mode handling for portaled content
- adds onboarding E2E coverage, including pending existing owners and
legacy started owners reaching Analytics normally
---
.../admin/src/hooks/user-preferences.test.tsx | 37 ++-
apps/admin/src/hooks/user-preferences.ts | 15 +-
.../components/onboarding-checklist.tsx | 102 ++++++
.../components/onboarding-logo-video.tsx | 40 +++
.../components/onboarding-step-item.tsx | 51 +++
.../components/share-publication-dialog.tsx | 84 +++++
apps/admin/src/onboarding/constants.ts | 44 +++
.../onboarding/hooks/use-onboarding.test.tsx | 209 ++++++++++++
.../src/onboarding/hooks/use-onboarding.ts | 96 ++++++
.../src/onboarding/onboarding-redirect.tsx | 23 ++
.../admin/src/onboarding/onboarding-route.tsx | 105 ++++++
apps/admin/src/routes.tsx | 13 +-
apps/admin/src/utils/deep-merge.ts | 2 +-
apps/shade/.storybook/preview.tsx | 43 ++-
.../post-share-modal/post-share-modal.tsx | 196 +++++-------
.../components/features/share-modal/index.ts | 2 +
.../share-modal/share-modal.stories.tsx | 300 ++++++++++++++++++
.../features/share-modal/share-modal.tsx | 269 ++++++++++++++++
apps/shade/src/patterns.ts | 2 +
e2e/helpers/pages/admin/index.ts | 1 +
e2e/helpers/pages/admin/onboarding/index.ts | 1 +
.../pages/admin/onboarding/onboarding-page.ts | 25 ++
e2e/tests/admin/onboarding.test.ts | 208 ++++++++++++
ghost/admin/app/routes/setup/done.js | 10 +-
ghost/admin/app/services/onboarding.js | 4 +-
ghost/admin/mirage/config/authentication.js | 11 +-
.../admin/tests/acceptance/onboarding-test.js | 25 +-
ghost/admin/tests/acceptance/setup-test.js | 21 +-
28 files changed, 1785 insertions(+), 154 deletions(-)
create mode 100644 apps/admin/src/onboarding/components/onboarding-checklist.tsx
create mode 100644 apps/admin/src/onboarding/components/onboarding-logo-video.tsx
create mode 100644 apps/admin/src/onboarding/components/onboarding-step-item.tsx
create mode 100644 apps/admin/src/onboarding/components/share-publication-dialog.tsx
create mode 100644 apps/admin/src/onboarding/constants.ts
create mode 100644 apps/admin/src/onboarding/hooks/use-onboarding.test.tsx
create mode 100644 apps/admin/src/onboarding/hooks/use-onboarding.ts
create mode 100644 apps/admin/src/onboarding/onboarding-redirect.tsx
create mode 100644 apps/admin/src/onboarding/onboarding-route.tsx
create mode 100644 apps/shade/src/components/features/share-modal/index.ts
create mode 100644 apps/shade/src/components/features/share-modal/share-modal.stories.tsx
create mode 100644 apps/shade/src/components/features/share-modal/share-modal.tsx
create mode 100644 e2e/helpers/pages/admin/onboarding/index.ts
create mode 100644 e2e/helpers/pages/admin/onboarding/onboarding-page.ts
create mode 100644 e2e/tests/admin/onboarding.test.ts
diff --git a/apps/admin/src/hooks/user-preferences.test.tsx b/apps/admin/src/hooks/user-preferences.test.tsx
index 6aeca134e6e..6d5e4f8d152 100644
--- a/apps/admin/src/hooks/user-preferences.test.tsx
+++ b/apps/admin/src/hooks/user-preferences.test.tsx
@@ -1,7 +1,7 @@
import { test as baseTest, describe, expect } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import type { QueryClient } from "@tanstack/react-query";
-import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES } from "./user-preferences";
+import { useUserPreferences, useEditUserPreferences, DEFAULT_NAVIGATION_PREFERENCES, DEFAULT_ONBOARDING_PREFERENCES } from "./user-preferences";
import { HttpResponse, http } from "msw";
import { mockUser } from "@test-utils/factories";
import { waitForQuerySettled } from "@test-utils/test-helpers";
@@ -30,6 +30,7 @@ const fixtures = {
},
defaults: {
navigation: DEFAULT_NAVIGATION_PREFERENCES,
+ onboarding: DEFAULT_ONBOARDING_PREFERENCES,
}
};
@@ -209,6 +210,11 @@ describe("useUserPreferences", () => {
queryTest("gracefully handles invalid schema values", async ({ setup }) => {
const result = await setup({
accessibility: JSON.stringify({
+ onboarding: {
+ checklistState: "unknown",
+ completedSteps: "customize-design",
+ startedAt: "not-a-valid-datetime",
+ },
whatsNew: {
lastSeenDate: "not-a-valid-datetime",
},
@@ -225,6 +231,24 @@ describe("useUserPreferences", () => {
});
});
+ queryTest("parses onboarding startedAt into a date", async ({ setup }) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: ["customize-design"],
+ checklistState: "started",
+ startedAt: "2026-04-30T10:00:00.000Z",
+ },
+ }),
+ });
+
+ expect(result.current.data?.onboarding).toEqual({
+ completedSteps: ["customize-design"],
+ checklistState: "started",
+ startedAt: new Date("2026-04-30T10:00:00.000Z"),
+ });
+ });
+
queryTest("returns undefined when user is not loaded", async ({ server, wrapper }) => {
server.use(
http.get(USERS_API_URL, () => {
@@ -322,6 +346,7 @@ describe("useUserPreferences", () => {
await waitFor(() => {
expect(result.current.query.data).toEqual({
navigation: DEFAULT_NAVIGATION_PREFERENCES,
+ onboarding: DEFAULT_ONBOARDING_PREFERENCES,
whatsNew: {
lastSeenDate: new Date("2025-01-01T00:00:00.000Z"),
},
@@ -349,6 +374,7 @@ describe("useUserPreferences", () => {
posts: false,
},
},
+ onboarding: DEFAULT_ONBOARDING_PREFERENCES,
whatsNew: {
lastSeenDate: new Date("2025-01-01T00:00:00.000Z"),
},
@@ -423,6 +449,10 @@ describe("useEditUserPreferences", () => {
expanded: { posts: false, members: false },
menu: { visible: true },
},
+ onboarding: {
+ completedSteps: ["customize-design"],
+ checklistState: "started",
+ },
nightShift: true,
}),
});
@@ -430,6 +460,7 @@ describe("useEditUserPreferences", () => {
await act(async () => {
await mutation.current.mutateAsync({
navigation: { expanded: { posts: true } },
+ onboarding: { completedSteps: ["customize-design", "first-post"] },
});
});
@@ -439,6 +470,10 @@ describe("useEditUserPreferences", () => {
expanded: { posts: true, members: false },
menu: { visible: true }, // Preserved
},
+ onboarding: {
+ completedSteps: ["customize-design", "first-post"],
+ checklistState: "started",
+ },
nightShift: true, // Preserved
});
});
diff --git a/apps/admin/src/hooks/user-preferences.ts b/apps/admin/src/hooks/user-preferences.ts
index 28a66b77d9c..d8d1781dd8f 100644
--- a/apps/admin/src/hooks/user-preferences.ts
+++ b/apps/admin/src/hooks/user-preferences.ts
@@ -10,6 +10,18 @@ const WhatsNewPreferencesSchema = z.looseObject({
lastSeenDate: isoDatetimeToDate.optional().catch(undefined),
});
+export const DEFAULT_ONBOARDING_PREFERENCES = {
+ completedSteps: [] as string[],
+ checklistState: "pending" as const,
+ startedAt: undefined as Date | undefined,
+};
+
+export const OnboardingPreferencesSchema = z.looseObject({
+ completedSteps: z.array(z.string()).default(DEFAULT_ONBOARDING_PREFERENCES.completedSteps).catch(DEFAULT_ONBOARDING_PREFERENCES.completedSteps),
+ checklistState: z.enum(["pending", "started", "completed", "dismissed"]).default(DEFAULT_ONBOARDING_PREFERENCES.checklistState).catch(DEFAULT_ONBOARDING_PREFERENCES.checklistState),
+ startedAt: isoDatetimeToDate.optional().catch(DEFAULT_ONBOARDING_PREFERENCES.startedAt),
+});
+
export const DEFAULT_NAVIGATION_PREFERENCES = {
expanded: { posts: true, members: true },
menu: { visible: true },
@@ -28,11 +40,13 @@ export const NavigationPreferencesSchema = z.looseObject({
const PreferencesSchema = z.looseObject({
whatsNew: WhatsNewPreferencesSchema.optional().catch(undefined),
nightShift: z.boolean().optional(),
+ onboarding: OnboardingPreferencesSchema.default(DEFAULT_ONBOARDING_PREFERENCES).catch(DEFAULT_ONBOARDING_PREFERENCES),
navigation: NavigationPreferencesSchema.default(DEFAULT_NAVIGATION_PREFERENCES).catch(DEFAULT_NAVIGATION_PREFERENCES),
});
export type Preferences = z.infer;
export type WhatsNewPreferences = z.infer;
+export type OnboardingPreferences = z.infer;
export type NavigationPreferences = z.infer;
const userPreferencesQueryKey = (user: User | undefined) => ["userPreferences", user?.id, user?.accessibility] as const;
@@ -80,7 +94,6 @@ export const useEditUserPreferences = (): UseMutationResult(userPreferencesQueryKey(user)) ?? PreferencesSchema.parse({});
- // TODO: use zod to validate?
const newPreferences = deepMerge(currentPreferences, updatedPreferences);
const encodedForStorage = PreferencesSchema.encode(newPreferences);
diff --git a/apps/admin/src/onboarding/components/onboarding-checklist.tsx b/apps/admin/src/onboarding/components/onboarding-checklist.tsx
new file mode 100644
index 00000000000..1476f970be1
--- /dev/null
+++ b/apps/admin/src/onboarding/components/onboarding-checklist.tsx
@@ -0,0 +1,102 @@
+import {Button} from "@tryghost/shade/components";
+import {LucideIcon} from "@tryghost/shade/utils";
+import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants";
+import {OnboardingLogoVideo} from "@/onboarding/components/onboarding-logo-video";
+import {OnboardingStepItem} from "@/onboarding/components/onboarding-step-item";
+
+interface OnboardingChecklistProps {
+ allStepsCompleted: boolean;
+ completedSteps: string[];
+ nextStep: OnboardingStep | undefined;
+ onComplete: () => void;
+ onDismiss: () => void;
+ onStepClick: (step: OnboardingStep) => void;
+ siteTitle: string;
+}
+
+export function OnboardingChecklist({
+ allStepsCompleted,
+ completedSteps,
+ nextStep,
+ onComplete,
+ onDismiss,
+ onStepClick,
+ siteTitle,
+}: OnboardingChecklistProps) {
+ const completedStepSet = new Set(completedSteps);
+
+ return (
+
+
+
+
+ {allStepsCompleted ?
+
You're all set.
+ :
+ <>
+
Let's get started!
+
Welcome! It's time to set up {siteTitle}.
+ >
+ }
+
+
+
+
+
+
+ Start a new Ghost publication
+
+
+
+
+
+
+ {ONBOARDING_STEPS.map((step, index) => (
+
onStepClick(step.id)}
+ />
+ ))}
+
+
+ {allStepsCompleted &&
+
+ }
+
+
+ More questions? Check out our{" "}
+ .
+
+
+ {!allStepsCompleted &&
+
+ }
+
+
+ );
+}
diff --git a/apps/admin/src/onboarding/components/onboarding-logo-video.tsx b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx
new file mode 100644
index 00000000000..ab245b2aa9c
--- /dev/null
+++ b/apps/admin/src/onboarding/components/onboarding-logo-video.tsx
@@ -0,0 +1,40 @@
+import logoLoaderDarkUrl from "@/assets/videos/logo-loader-dark.mp4";
+import logoLoaderUrl from "@/assets/videos/logo-loader.mp4";
+
+export function OnboardingLogoVideo() {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/onboarding/components/onboarding-step-item.tsx b/apps/admin/src/onboarding/components/onboarding-step-item.tsx
new file mode 100644
index 00000000000..0f9a30e02ed
--- /dev/null
+++ b/apps/admin/src/onboarding/components/onboarding-step-item.tsx
@@ -0,0 +1,51 @@
+import {LucideIcon} from "@tryghost/shade/utils";
+import {type OnboardingStepDefinition} from "@/onboarding/constants";
+
+interface OnboardingStepItemProps {
+ complete: boolean;
+ id: string;
+ isBeforeNext: boolean;
+ isLast: boolean;
+ isNext: boolean;
+ onClick: () => void;
+ step: OnboardingStepDefinition;
+}
+
+export function OnboardingStepItem({
+ complete,
+ id,
+ isBeforeNext,
+ isLast,
+ isNext,
+ onClick,
+ step,
+}: OnboardingStepItemProps) {
+ const Icon = step.icon;
+ const hideBorder = isLast || isBeforeNext || isNext;
+ const rowClassName = isNext
+ ? `relative z-10 -mx-8 flex w-[calc(100%+64px)] items-center justify-between rounded-md bg-background px-8 py-6 text-left shadow-[0_1px_0_rgba(17,17,26,0.05),0_0_8px_rgba(17,17,26,0.10)] transition-none dark:ring-1 dark:ring-border ${isLast ? "-mb-[18px]" : "mb-1.5"}`
+ : `relative flex w-full items-center justify-between bg-transparent py-6 text-left ${hideBorder ? "" : "after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:content-['']"}`;
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/onboarding/components/share-publication-dialog.tsx b/apps/admin/src/onboarding/components/share-publication-dialog.tsx
new file mode 100644
index 00000000000..2bad59dadd6
--- /dev/null
+++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx
@@ -0,0 +1,84 @@
+import {Button} from "@tryghost/shade/components";
+import {ShareModal, type ShareModalSocialLink} from "@tryghost/shade/patterns";
+
+interface SharePublicationDialogProps {
+ description: string;
+ imageUrl: string;
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+ siteTitle: string;
+ siteUrl: string;
+}
+
+export function SharePublicationDialog({
+ description,
+ imageUrl,
+ onOpenChange,
+ open,
+ siteTitle,
+ siteUrl,
+}: SharePublicationDialogProps) {
+ const encodedUrl = encodeURIComponent(siteUrl);
+ const socialLinks: ShareModalSocialLink[] = [
+ {
+ href: `https://twitter.com/intent/tweet?url=${encodedUrl}`,
+ id: "ob-share-on-x",
+ label: "Share your publication on X",
+ service: "x",
+ },
+ {
+ href: `https://threads.net/intent/post?text=${encodedUrl}`,
+ id: "ob-share-on-threads",
+ label: "Share your publication on Threads",
+ service: "threads",
+ },
+ {
+ href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
+ id: "ob-share-on-fb",
+ label: "Share your publication on Facebook",
+ service: "facebook",
+ },
+ {
+ href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedUrl}`,
+ id: "ob-share-on-li",
+ label: "Share your publication on LinkedIn",
+ service: "linkedin",
+ },
+ ];
+
+ return (
+
+ Set your publication's cover image and description in{" "}
+ .
+
+ )}
+ open={open}
+ preview={{
+ description,
+ imageURL: imageUrl,
+ title: siteTitle,
+ url: siteUrl,
+ }}
+ socialLinks={socialLinks}
+ title="Share your publication"
+ variant="publication"
+ onClose={() => onOpenChange(false)}
+ onOpenChange={onOpenChange}
+ />
+ );
+}
diff --git a/apps/admin/src/onboarding/constants.ts b/apps/admin/src/onboarding/constants.ts
new file mode 100644
index 00000000000..b324c505a3b
--- /dev/null
+++ b/apps/admin/src/onboarding/constants.ts
@@ -0,0 +1,44 @@
+import type React from "react";
+import {LucideIcon} from "@tryghost/shade/utils";
+
+type OnboardingStepDefinitionShape = {
+ description: string;
+ icon: React.ComponentType<{className?: string}>;
+ id: string;
+ route?: string;
+ title: string;
+};
+
+export const ONBOARDING_STEPS = [
+ {
+ description: "Craft a look that reflects your brand and style.",
+ icon: LucideIcon.Brush,
+ id: "customize-design",
+ route: "/settings/design/edit?ref=setup",
+ title: "Customize your design",
+ },
+ {
+ description: "Get to know a writing experience you'll love.",
+ icon: LucideIcon.PenLine,
+ id: "first-post",
+ route: "/editor/post",
+ title: "Explore the editor",
+ },
+ {
+ description: "Add members and grow your readership.",
+ icon: LucideIcon.UserPlus,
+ id: "build-audience",
+ route: "/members",
+ title: "Build your audience",
+ },
+ {
+ description: "Expand your reach on social media.",
+ icon: LucideIcon.Megaphone,
+ id: "share-publication",
+ route: undefined,
+ title: "Share your publication",
+ },
+] as const satisfies readonly OnboardingStepDefinitionShape[];
+
+export type OnboardingStepDefinition = typeof ONBOARDING_STEPS[number];
+export type OnboardingStep = OnboardingStepDefinition["id"];
diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx
new file mode 100644
index 00000000000..a0de978195c
--- /dev/null
+++ b/apps/admin/src/onboarding/hooks/use-onboarding.test.tsx
@@ -0,0 +1,209 @@
+import {act, renderHook, waitFor} from "@testing-library/react";
+import {describe, expect, test as baseTest} from "vitest";
+import {HttpResponse, http} from "msw";
+import {mockUser} from "@test-utils/factories";
+import {queryClientFixtures, type TestWrapperComponent} from "@test-utils/fixtures/query-client";
+import {serverFixture} from "@test-utils/fixtures/msw";
+import {useOnboarding} from "./use-onboarding";
+import type {QueryClient} from "@tanstack/react-query";
+import type {SetupServer} from "msw/node";
+import type {UpdateUserRequestBody, UsersResponseType, User} from "@tryghost/admin-x-framework/api/users";
+
+const USERS_API_URL = "/ghost/api/admin/users/me/";
+const USER_UPDATE_API_URL = "/ghost/api/admin/users/:id/";
+
+const ownerRole = {
+ id: "owner-role",
+ name: "Owner",
+ description: "Owner",
+ created_at: "2024-01-01T00:00:00.000Z",
+ updated_at: "2024-01-01T00:00:00.000Z",
+} as const;
+
+async function setupOnboarding(
+ server: SetupServer,
+ wrapper: TestWrapperComponent,
+ userOverrides: Partial = {}
+) {
+ server.use(
+ http.get(USERS_API_URL, () => {
+ return HttpResponse.json({
+ users: [{
+ ...mockUser,
+ roles: [ownerRole],
+ ...userOverrides,
+ }],
+ });
+ }),
+ http.put<{ id: string }, UpdateUserRequestBody, UsersResponseType>(
+ USER_UPDATE_API_URL,
+ async ({request}) => {
+ const body = await request.json();
+ return HttpResponse.json({
+ users: [{
+ ...mockUser,
+ roles: [ownerRole],
+ accessibility: body.users[0]?.accessibility ?? "",
+ }],
+ });
+ }
+ )
+ );
+
+ const {result} = renderHook(() => useOnboarding(), {wrapper});
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+ return result;
+}
+
+const onboardingTest = baseTest.extend<{
+ server: SetupServer;
+ queryClient: QueryClient;
+ wrapper: TestWrapperComponent;
+ setup: (userOverrides?: Partial) => ReturnType;
+}>({
+ ...serverFixture,
+ ...queryClientFixtures,
+ setup: async ({server, wrapper}, provide) => {
+ await provide((userOverrides) => setupOnboarding(server, wrapper, userOverrides));
+ },
+});
+
+describe("useOnboarding", () => {
+ onboardingTest("shows checklist for owners when onboarding is started", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: ["customize-design"],
+ checklistState: "started",
+ startedAt: "2026-04-30T10:00:00.000Z",
+ },
+ }),
+ });
+
+ expect(result.current.shouldShowChecklist).toBe(true);
+ expect(result.current.nextStep).toBe("first-post");
+ expect(result.current.allStepsCompleted).toBe(false);
+ });
+
+ onboardingTest("does not show checklist for non-owner users", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: [],
+ checklistState: "started",
+ startedAt: "2026-04-30T10:00:00.000Z",
+ },
+ }),
+ roles: [{
+ id: "admin-role",
+ name: "Administrator",
+ description: "Admin",
+ created_at: "2024-01-01T00:00:00.000Z",
+ updated_at: "2024-01-01T00:00:00.000Z",
+ }],
+ });
+
+ expect(result.current.shouldShowChecklist).toBe(false);
+ });
+
+ onboardingTest("updates completed steps without duplicating existing steps", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: ["customize-design"],
+ checklistState: "started",
+ startedAt: "2026-04-30T10:00:00.000Z",
+ },
+ }),
+ });
+
+ await act(async () => {
+ await result.current.markStepCompleted("customize-design");
+ await result.current.markStepCompleted("first-post");
+ });
+
+ await waitFor(() => {
+ expect(result.current.completedSteps).toEqual(["customize-design", "first-post"]);
+ });
+ });
+
+ onboardingTest("updates checklist state", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: [],
+ checklistState: "started",
+ startedAt: "2026-04-30T10:00:00.000Z",
+ },
+ }),
+ });
+
+ await act(async () => {
+ await result.current.dismissChecklist();
+ });
+
+ await waitFor(() => {
+ expect(result.current.checklistState).toBe("dismissed");
+ expect(result.current.shouldShowChecklist).toBe(false);
+ });
+ });
+
+ onboardingTest("starts checklist with a start date", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: ["customize-design"],
+ checklistState: "pending",
+ },
+ }),
+ });
+
+ await act(async () => {
+ await result.current.startChecklist();
+ });
+
+ await waitFor(() => {
+ expect(result.current.checklistState).toBe("started");
+ expect(result.current.completedSteps).toEqual([]);
+ expect(result.current.hasActiveStartedAt).toBe(true);
+ expect(result.current.shouldShowChecklist).toBe(true);
+ });
+ });
+
+ onboardingTest("dismisses started checklist when startedAt is missing", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: [],
+ checklistState: "started",
+ },
+ }),
+ });
+
+ expect(result.current.shouldShowChecklist).toBe(false);
+
+ await waitFor(() => {
+ expect(result.current.checklistState).toBe("dismissed");
+ });
+ });
+
+ onboardingTest("dismisses started checklist when startedAt is before the cutoff", async ({setup}) => {
+ const result = await setup({
+ accessibility: JSON.stringify({
+ onboarding: {
+ completedSteps: [],
+ checklistState: "started",
+ startedAt: "2026-04-29T23:59:59.999Z",
+ },
+ }),
+ });
+
+ expect(result.current.shouldShowChecklist).toBe(false);
+
+ await waitFor(() => {
+ expect(result.current.checklistState).toBe("dismissed");
+ });
+ });
+});
diff --git a/apps/admin/src/onboarding/hooks/use-onboarding.ts b/apps/admin/src/onboarding/hooks/use-onboarding.ts
new file mode 100644
index 00000000000..e9d25a8307b
--- /dev/null
+++ b/apps/admin/src/onboarding/hooks/use-onboarding.ts
@@ -0,0 +1,96 @@
+import {useCallback, useEffect, useMemo, useRef} from "react";
+import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user";
+import {isOwnerUser} from "@tryghost/admin-x-framework/api/users";
+import {useEditUserPreferences, useUserPreferences} from "@/hooks/user-preferences";
+import type {OnboardingPreferences} from "@/hooks/user-preferences";
+import {ONBOARDING_STEPS, type OnboardingStep} from "@/onboarding/constants";
+
+const ONBOARDING_STARTED_AT_CUTOFF = new Date("2026-04-30T00:00:00.000Z");
+
+function isAfterOnboardingStartedAtCutoff(date: Date | undefined) {
+ if (!date) {
+ return false;
+ }
+
+ return date >= ONBOARDING_STARTED_AT_CUTOFF;
+}
+
+export function useOnboarding() {
+ const {data: currentUser, isLoading: isUserLoading} = useCurrentUser();
+ const {data: preferences, isLoading: isPreferencesLoading} = useUserPreferences();
+ const {mutateAsync: editPreferences} = useEditUserPreferences();
+ const hasAttemptedInvalidStartedStateDismissalRef = useRef(false);
+
+ const completedSteps = useMemo(() => preferences?.onboarding.completedSteps || [], [preferences?.onboarding.completedSteps]);
+ const completedStepSet = useMemo(() => new Set(completedSteps), [completedSteps]);
+ const checklistState = preferences?.onboarding.checklistState || "pending";
+ const startedAt = preferences?.onboarding.startedAt;
+ const hasActiveStartedAt = isAfterOnboardingStartedAtCutoff(startedAt);
+ const isOwner = currentUser ? isOwnerUser(currentUser) : false;
+ const shouldShowChecklist = isOwner && checklistState === "started" && hasActiveStartedAt;
+ const nextStep = ONBOARDING_STEPS.find(step => !completedStepSet.has(step.id))?.id;
+ const allStepsCompleted = ONBOARDING_STEPS.every(step => completedStepSet.has(step.id));
+
+ const updateOnboarding = useCallback((updates: {
+ completedSteps?: string[];
+ checklistState?: OnboardingPreferences["checklistState"];
+ startedAt?: Date;
+ }) => {
+ return editPreferences({
+ onboarding: updates,
+ });
+ }, [editPreferences]);
+
+ const markStepCompleted = useCallback(async (step: OnboardingStep) => {
+ if (completedStepSet.has(step)) {
+ return;
+ }
+
+ await updateOnboarding({
+ completedSteps: [...completedSteps, step],
+ });
+ }, [completedStepSet, completedSteps, updateOnboarding]);
+
+ const dismissChecklist = useCallback(() => {
+ return updateOnboarding({checklistState: "dismissed"});
+ }, [updateOnboarding]);
+
+ useEffect(() => {
+ if (isUserLoading || isPreferencesLoading || !isOwner || checklistState !== "started" || hasActiveStartedAt || hasAttemptedInvalidStartedStateDismissalRef.current) {
+ return;
+ }
+
+ hasAttemptedInvalidStartedStateDismissalRef.current = true;
+ void dismissChecklist().catch((error) => {
+ hasAttemptedInvalidStartedStateDismissalRef.current = false;
+ console.error(error);
+ });
+ }, [checklistState, dismissChecklist, hasActiveStartedAt, isOwner, isPreferencesLoading, isUserLoading]);
+
+ const startChecklist = useCallback(() => {
+ return updateOnboarding({
+ completedSteps: [],
+ checklistState: "started",
+ startedAt: new Date(),
+ });
+ }, [updateOnboarding]);
+
+ const completeChecklist = useCallback(() => {
+ return updateOnboarding({checklistState: "completed"});
+ }, [updateOnboarding]);
+
+ return {
+ allStepsCompleted,
+ checklistState,
+ completeChecklist,
+ completedSteps,
+ dismissChecklist,
+ hasActiveStartedAt,
+ isOwner,
+ shouldShowChecklist,
+ isLoading: isUserLoading || isPreferencesLoading,
+ markStepCompleted,
+ nextStep,
+ startChecklist,
+ };
+}
diff --git a/apps/admin/src/onboarding/onboarding-redirect.tsx b/apps/admin/src/onboarding/onboarding-redirect.tsx
new file mode 100644
index 00000000000..21f288772db
--- /dev/null
+++ b/apps/admin/src/onboarding/onboarding-redirect.tsx
@@ -0,0 +1,23 @@
+import type React from "react";
+import {Navigate, useLocation} from "@tryghost/admin-x-framework";
+import {useOnboarding} from "@/onboarding/hooks/use-onboarding";
+
+interface OnboardingRedirectProps {
+ children: React.ReactNode;
+}
+
+export function OnboardingRedirect({children}: OnboardingRedirectProps) {
+ const location = useLocation();
+ const onboarding = useOnboarding();
+
+ if (onboarding.isLoading) {
+ return null;
+ }
+
+ if (onboarding.shouldShowChecklist) {
+ const returnTo = `${location.pathname}${location.search}`;
+ return ;
+ }
+
+ return children;
+}
diff --git a/apps/admin/src/onboarding/onboarding-route.tsx b/apps/admin/src/onboarding/onboarding-route.tsx
new file mode 100644
index 00000000000..dd441811fb9
--- /dev/null
+++ b/apps/admin/src/onboarding/onboarding-route.tsx
@@ -0,0 +1,105 @@
+import {Navigate, useNavigate, useSearchParams} from "@tryghost/admin-x-framework";
+import {getSettingValue, useBrowseSettings} from "@tryghost/admin-x-framework/api/settings";
+import {useBrowseSite} from "@tryghost/admin-x-framework/api/site";
+import {useRef, useState} from "react";
+import {OnboardingChecklist} from "@/onboarding/components/onboarding-checklist";
+import {SharePublicationDialog} from "@/onboarding/components/share-publication-dialog";
+import {useOnboarding} from "@/onboarding/hooks/use-onboarding";
+import {ONBOARDING_STEPS, type OnboardingStep} from "./constants";
+
+function getSafeReturnTo(value: string | null) {
+ return value && /^\/analytics(?:\/|\?|$)/.test(value) ? value : "/analytics";
+}
+
+export default function OnboardingRoute() {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const returnTo = getSafeReturnTo(searchParams.get("returnTo"));
+ const onboarding = useOnboarding();
+ const settings = useBrowseSettings();
+ const site = useBrowseSite();
+ const isLeavingRef = useRef(false);
+ const [isLeaving, setIsLeaving] = useState(false);
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
+
+ const siteTitle = String(getSettingValue(settings.data?.settings, "title") || site.data?.site.title || "your publication");
+ const description = String(getSettingValue(settings.data?.settings, "description") || site.data?.site.description || "");
+ const imageUrl = String(getSettingValue(settings.data?.settings, "cover_image") || "");
+ const siteUrl = site.data?.site.url || "/";
+
+ const {
+ allStepsCompleted,
+ completeChecklist,
+ completedSteps,
+ dismissChecklist,
+ shouldShowChecklist,
+ isLoading,
+ markStepCompleted,
+ nextStep,
+ } = onboarding;
+
+ if (isLoading || site.isLoading || isLeaving || isLeavingRef.current) {
+ return null;
+ }
+
+ if (!shouldShowChecklist) {
+ return ;
+ }
+
+ const navigateAfterUpdate = async (update: () => Promise) => {
+ isLeavingRef.current = true;
+ setIsLeaving(true);
+ try {
+ await update();
+ navigate(returnTo, {crossApp: true, replace: true});
+ } catch (error) {
+ isLeavingRef.current = false;
+ setIsLeaving(false);
+ console.error(error);
+ }
+ };
+
+ const handleStepClick = async (step: OnboardingStep) => {
+ if (step === "share-publication") {
+ await markStepCompleted(step);
+ setShareDialogOpen(true);
+ return;
+ }
+
+ await markStepCompleted(step);
+
+ const stepRoute = ONBOARDING_STEPS.find(({id}) => id === step)?.route;
+ if (stepRoute) {
+ navigate(stepRoute, {crossApp: true});
+ }
+ };
+
+ return (
+ <>
+ {
+ void navigateAfterUpdate(completeChecklist);
+ }}
+ onDismiss={() => {
+ void navigateAfterUpdate(dismissChecklist);
+ }}
+ onStepClick={(step) => {
+ void handleStepClick(step);
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx
index c3dd3ef440a..17b8893affe 100644
--- a/apps/admin/src/routes.tsx
+++ b/apps/admin/src/routes.tsx
@@ -14,6 +14,7 @@ import MyProfileRedirect from "./my-profile-redirect";
import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge";
import type { RouteHandle } from "./ember-bridge";
import { MembersRoute } from "./members-route";
+import { OnboardingRedirect } from "./onboarding/onboarding-redirect";
import { NotFound } from "./not-found";
@@ -98,12 +99,18 @@ export const routes: RouteObject[] = [
},
{
element: (
-
-
-
+
+
+
+
+
),
children: statsRoutes,
},
+ {
+ path: "setup/onboarding",
+ lazy: lazyComponent(() => import("./onboarding/onboarding-route")),
+ },
{
path: `network`,
loader: () => redirect("/activitypub"),
diff --git a/apps/admin/src/utils/deep-merge.ts b/apps/admin/src/utils/deep-merge.ts
index 6141c33a8af..531ada9acc9 100644
--- a/apps/admin/src/utils/deep-merge.ts
+++ b/apps/admin/src/utils/deep-merge.ts
@@ -1,7 +1,7 @@
/**
* Deep partial type that makes all properties optional recursively.
*/
-export type DeepPartial = T extends object ? {
+export type DeepPartial = T extends Array ? Array> : T extends object ? {
[P in keyof T]?: DeepPartial;
} : T;
diff --git a/apps/shade/.storybook/preview.tsx b/apps/shade/.storybook/preview.tsx
index 27be90dfa23..fae5019a4d4 100644
--- a/apps/shade/.storybook/preview.tsx
+++ b/apps/shade/.storybook/preview.tsx
@@ -45,6 +45,35 @@ const customViewports = {
},
};
+const StorybookSchemeDecorator = ({Story, scheme}: {Story: React.ComponentType; scheme: string}) => {
+ React.useEffect(() => {
+ const isDark = scheme === 'dark';
+
+ document.documentElement.classList.toggle('dark', isDark);
+ document.body.classList.toggle('dark', isDark);
+
+ return () => {
+ document.documentElement.classList.remove('dark');
+ document.body.classList.remove('dark');
+ };
+ }, [scheme]);
+
+ return (
+
+ {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */}
+
+
+
+
+ );
+};
+
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
@@ -88,19 +117,7 @@ const preview: Preview = {
(Story, context) => {
let {scheme} = context.globals;
- return (
-
- {/* 👇 Decorators in Storybook also accept a function. Replace with Story() to enable it */}
-
-
-
-
);
+ return ;
},
],
globalTypes: {
diff --git a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx
index 1e1a5fb826a..d6167b17198 100644
--- a/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx
+++ b/apps/shade/src/components/features/post-share-modal/post-share-modal.tsx
@@ -1,137 +1,101 @@
-import {H3} from '@/components/layout/heading';
+import ShareModal, {type ShareModalSocialLink} from '@/components/features/share-modal/share-modal';
import {Button} from '@/components/ui/button';
-import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
-import {Check, Link, X} from 'lucide-react';
-import React, {useState} from 'react';
+import React from 'react';
interface PostShareModalProps extends React.ComponentPropsWithoutRef {
+ author?: string;
+ children?: React.ReactNode;
+ description?: React.ReactNode;
emailOnly?: boolean;
+ faviconURL?: string;
+ featureImageURL?: string;
+ onClose?: () => void;
+ postExcerpt?: string;
+ postTitle?: string;
postURL?: string;
primaryTitle?: string;
secondaryTitle?: string;
- description?: React.ReactNode;
- featureImageURL?: string;
- postTitle?: string;
- postExcerpt?: string;
- faviconURL?: string;
siteTitle?: string;
- author?: string;
- onClose?: () => void;
- children?: React.ReactNode;
}
-const PostShareModal: React.FC = (
- {emailOnly = false,
- postURL = '',
- primaryTitle = 'Your post is published.',
- secondaryTitle = 'Spread the word!',
- description = '',
- featureImageURL = '',
- postTitle = '',
- postExcerpt = '',
- faviconURL = '',
- siteTitle = '',
- author = '',
- onClose = () => {},
- children,
- ...props}) => {
- const [isCopied, setIsCopied] = useState(false);
-
- const handleCopyLink = async () => {
- try {
- await navigator.clipboard.writeText(postURL);
- setIsCopied(true);
- // Reset the copied state after 2 seconds
- setTimeout(() => setIsCopied(false), 2000);
- } catch {
- // Could add toast notification for copy failure
- }
- };
-
+const PostShareModal: React.FC = ({
+ author = '',
+ children,
+ description = '',
+ emailOnly = false,
+ faviconURL = '',
+ featureImageURL = '',
+ onClose = () => {},
+ postExcerpt = '',
+ postTitle = '',
+ postURL = '',
+ primaryTitle = 'Your post is published.',
+ secondaryTitle = 'Spread the word!',
+ siteTitle = '',
+ ...props
+}) => {
const encodedPostTitle = encodeURIComponent(postTitle);
const encodedPostURL = encodeURIComponent(postURL);
const encodedPostURLTitle = encodeURIComponent(`${postTitle} ${postURL}`);
+ const socialLinks: ShareModalSocialLink[] = emailOnly ? [] : [
+ {
+ href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostURL}`,
+ label: 'Share on X',
+ service: 'x'
+ },
+ {
+ href: `https://threads.net/intent/post?text=${encodedPostURLTitle}`,
+ label: 'Share on Threads',
+ service: 'threads'
+ },
+ {
+ href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPostURL}`,
+ label: 'Share on Facebook',
+ service: 'facebook'
+ },
+ {
+ href: `https://www.linkedin.com/shareArticle?mini=true&title=${encodedPostTitle}&url=${encodedPostURL}`,
+ label: 'Share on LinkedIn',
+ service: 'linkedin'
+ }
+ ];
return (
-