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..72e51efc54c
--- /dev/null
+++ b/apps/admin/src/onboarding/components/share-publication-dialog.tsx
@@ -0,0 +1,83 @@
+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/posts/src/views/members/member-fields.ts b/apps/posts/src/views/members/member-fields.ts
index e6127d04de0..f7ccdea8729 100644
--- a/apps/posts/src/views/members/member-fields.ts
+++ b/apps/posts/src/views/members/member-fields.ts
@@ -389,8 +389,7 @@ export const memberFields = defineFields({
ui: {
label: 'Emails sent (all time)',
type: 'number',
- defaultOperator: 'is',
- defaultValue: 0,
+ defaultOperator: 'is-greater',
min: 0,
className: 'w-24'
},
@@ -401,8 +400,7 @@ export const memberFields = defineFields({
ui: {
label: 'Emails opened (all time)',
type: 'number',
- defaultOperator: 'is',
- defaultValue: 0,
+ defaultOperator: 'is-greater',
min: 0,
className: 'w-24'
},
@@ -413,8 +411,7 @@ export const memberFields = defineFields({
ui: {
label: 'Open rate (all time)',
type: 'number',
- defaultOperator: 'is',
- defaultValue: 0,
+ defaultOperator: 'is-greater',
min: 0,
max: 100,
suffix: '%',
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 (
-