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 2e3ff361296..ec7d3e611b4 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].*" }, @@ -87,19 +116,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 ( - - - {children} - - -
- -
- - - {primaryTitle}
- {secondaryTitle} -
- {description && - - {description} - - } -
- - {featureImageURL && -
- } -
+ ), + title: postTitle, + url: postURL + }} + primaryTitle={primaryTitle} + secondaryTitle={secondaryTitle} + socialLinks={socialLinks} + variant="post" + onClose={onClose} + {...props} + > + {children} +
); }; diff --git a/apps/shade/src/components/features/share-modal/index.ts b/apps/shade/src/components/features/share-modal/index.ts new file mode 100644 index 00000000000..f2e2a3d2f94 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/index.ts @@ -0,0 +1,2 @@ +export {default} from './share-modal'; +export type {ShareModalSocialLink} from './share-modal'; diff --git a/apps/shade/src/components/features/share-modal/share-modal.stories.tsx b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx new file mode 100644 index 00000000000..5233e7d0b23 --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.stories.tsx @@ -0,0 +1,300 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Button} from '@/components/ui/button'; +import ShareModal, {type ShareModalSocialLink} from './share-modal'; + +const meta = { + title: 'Features / Share Modal', + component: ShareModal, + tags: ['autodocs'], + argTypes: { + children: { + table: { + disable: true + } + } + } +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); +const encodedPostTitleAndUrl = encodeURIComponent(`Copy to Clipboard in React: Complete Guide ${postUrl}`); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + +const postSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?text=${encodedPostTitle}%0A${encodedPostUrl}`, + label: 'Share on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPostTitleAndUrl}`, + 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' + } +]; + +const publicationSocialLinks: ShareModalSocialLink[] = [ + { + href: `https://twitter.com/intent/tweet?url=${encodedPublicationUrl}`, + label: 'Share your publication on X', + service: 'x' + }, + { + href: `https://threads.net/intent/post?text=${encodedPublicationUrl}`, + label: 'Share your publication on Threads', + service: 'threads' + }, + { + href: `https://www.facebook.com/sharer/sharer.php?u=${encodedPublicationUrl}`, + label: 'Share your publication on Facebook', + service: 'facebook' + }, + { + href: `https://www.linkedin.com/feed/?shareActive=true&text=${encodedPublicationUrl}`, + label: 'Share your publication on LinkedIn', + service: 'linkedin' + } +]; + +const postArgs = { + copyURL: postUrl, + description: <> + Your post was published on your site and sent to 3 subscribers of Ghost Blog, on June 13th at 12:02. + , + preview: { + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications with proper error handling and user feedback.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }, + primaryTitle: 'Your post is published.', + secondaryTitle: 'Spread the word!', + socialLinks: postSocialLinks, + variant: 'post' as const +}; + +const publicationArgs = { + actionsLayout: 'footer' as const, + copyURL: publicationUrl, + guidance: ( +

+ Set your publication's cover image and description in . +

+ ), + preview: { + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }, + socialLinks: publicationSocialLinks, + title: 'Share your publication', + variant: 'publication' as const +}; + +const postSource = `const [isOpen, setIsOpen] = useState(false); + +const postUrl = 'https://example.com/copy-clipboard-react-guide'; +const encodedPostTitle = encodeURIComponent('Copy to Clipboard in React: Complete Guide'); +const encodedPostUrl = encodeURIComponent(postUrl); + +Your post was published on your site and sent to 3 subscribers.} + open={isOpen} + preview={{ + description: 'A comprehensive guide to implementing copy-to-clipboard functionality in React applications.', + imageURL: 'https://picsum.photos/800/600?random=1', + meta: ( +
+
+
+ Ghost Blog + + Jane Smith +
+
+ ), + title: 'Copy to Clipboard in React: Complete Guide', + url: postUrl + }} + primaryTitle="Your post is published." + secondaryTitle="Spread the word!" + socialLinks={[ + { + href: \`https://twitter.com/intent/tweet?text=\${encodedPostTitle}%0A\${encodedPostUrl}\`, + label: 'Share on X', + service: 'x' + } + ]} + variant="post" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +`; + +const publicationSource = `const [isOpen, setIsOpen] = useState(false); + +const publicationUrl = 'https://ghost.org'; +const encodedPublicationUrl = encodeURIComponent(publicationUrl); + + + Set your publication's cover image and description in{' '} + . +

+ )} + open={isOpen} + preview={{ + description: 'Thoughts, stories and ideas.', + imageURL: 'https://picsum.photos/800/600?random=2', + title: 'Ghostbusters', + url: publicationUrl + }} + socialLinks={[ + { + href: \`https://threads.net/intent/post?text=\${encodedPublicationUrl}\`, + label: 'Share your publication on Threads', + service: 'threads' + } + ]} + title="Share your publication" + variant="publication" + onClose={() => setIsOpen(false)} + onOpenChange={setIsOpen} +> + +
`; + +export const Post: Story = { + args: { + ...postArgs + }, + render: (args) => { + const PostExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: postSource + } + } + } +}; + +export const Publication: Story = { + args: { + ...publicationArgs + }, + render: (args) => { + const PublicationExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + }, + parameters: { + docs: { + source: { + code: publicationSource + } + } + } +}; + +export const ControlledPublication: Story = { + args: { + copyURL: publicationUrl, + preview: { + title: 'Ghostbusters', + url: publicationUrl + } + }, + parameters: { + docs: { + source: { + code: publicationSource.replace('Share publication', 'Open publication share modal') + } + } + }, + render: () => { + const ControlledExample = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(false)} + onOpenChange={setIsOpen} + > + + + ); + }; + + return ; + } +}; diff --git a/apps/shade/src/components/features/share-modal/share-modal.tsx b/apps/shade/src/components/features/share-modal/share-modal.tsx new file mode 100644 index 00000000000..723636456aa --- /dev/null +++ b/apps/shade/src/components/features/share-modal/share-modal.tsx @@ -0,0 +1,269 @@ +import {H3} from '@/components/layout/heading'; +import {Button} from '@/components/ui/button'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; +import {cn} from '@/lib/utils'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import {Check, Copy, Image as ImageIcon, Link, X} from 'lucide-react'; +import React, {useState} from 'react'; + +type ShareService = 'x' | 'threads' | 'facebook' | 'linkedin'; + +export type ShareModalSocialLink = { + href: string; + id?: string; + label: string; + service: ShareService; + title?: string; +}; + +interface ShareModalPreview { + description?: React.ReactNode; + imageURL?: string; + meta?: React.ReactNode; + title: React.ReactNode; + url: string; +} + +interface ShareModalProps extends React.ComponentPropsWithoutRef { + actionsLayout?: 'footer' | 'stacked'; + children?: React.ReactNode; + closeButtonId?: string; + copyButtonId?: string; + copyButtonTestId?: string; + copyLabel?: string; + copySuccessLabel?: string; + copyURL: string; + contentProps?: React.ComponentPropsWithoutRef; + description?: React.ReactNode; + footerAction?: React.ReactNode; + guidance?: React.ReactNode; + onClose?: () => void; + preview: ShareModalPreview; + primaryTitle?: React.ReactNode; + secondaryTitle?: React.ReactNode; + socialLinks?: ShareModalSocialLink[]; + title?: React.ReactNode; + variant?: 'post' | 'publication'; +} + +async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for browser contexts where the async clipboard API is blocked. + } + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); +} + +function SocialIcon({service}: {service: ShareService}) { + if (service === 'threads') { + return ( + + ); + } + + if (service === 'facebook') { + return ( + + ); + } + + if (service === 'linkedin') { + return ( + + ); + } + + return ( + + ); +} + +function SocialLinks({layout, links}: {layout: 'footer' | 'stacked'; links: ShareModalSocialLink[]}) { + if (layout === 'stacked') { + return ( +
+ {links.map(link => ( + + ))} +
+ ); + } + + return ( +
+ {links.map(link => ( + + + + ))} +
+ ); +} + +const ShareModal: React.FC = ({ + actionsLayout = 'footer', + children, + closeButtonId, + copyButtonId, + copyButtonTestId, + copyLabel = 'Copy link', + copySuccessLabel = 'Copied!', + copyURL, + contentProps, + description, + footerAction, + guidance, + onClose = () => {}, + preview, + primaryTitle, + secondaryTitle, + socialLinks = [], + title, + variant = 'post', + ...props +}) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopyLink = async () => { + await copyTextToClipboard(copyURL); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + const showPostHeader = variant === 'post'; + const {className: contentClassName, ...dialogContentProps} = contentProps || {}; + const content = ( + + {showPostHeader && ( +
+ +
+ )} + + {showPostHeader ? + + {primaryTitle && {primaryTitle}} + {primaryTitle && secondaryTitle &&
} + {secondaryTitle && {secondaryTitle}} +
+ : + {title} + } + {!showPostHeader && ( + + )} + {description && + + {description} + + } +
+ + + {preview.imageURL ? +
+ : + !showPostHeader && ( +
+ +
+ ) + } +
+ {showPostHeader ? +

{preview.title}

+ : +
{preview.title}
+ } + {preview.description && ( +

{preview.description}

+ )} + {preview.meta} +
+
+ + {guidance} + + {actionsLayout === 'stacked' ? + <> +
+ {copyURL} + +
+ + + : + + {footerAction || ( + <> + + + + )} + + } +
+ ); + + return ( + + {children ? + + {children} + + : + null + } + {content} + + ); +}; + +export default ShareModal; diff --git a/apps/shade/src/patterns.ts b/apps/shade/src/patterns.ts index 9781c117bcc..d81122ada30 100644 --- a/apps/shade/src/patterns.ts +++ b/apps/shade/src/patterns.ts @@ -3,6 +3,8 @@ export * from './components/ui/filters'; export {default as ColorPicker} from './components/features/color-picker/color-picker'; export type {ColorPickerProps} from './components/features/color-picker/color-picker'; export {default as PostShareModal} from './components/features/post-share-modal'; +export {default as ShareModal} from './components/features/share-modal'; +export type {ShareModalSocialLink} from './components/features/share-modal'; export * from './components/features/table-filter-tabs/table-filter-tabs'; export * from './components/features/utm-campaign-tabs/utm-campaign-tabs'; export type {CampaignType, TabType} from './components/features/utm-campaign-tabs/utm-campaign-tabs'; diff --git a/e2e/helpers/pages/admin/index.ts b/e2e/helpers/pages/admin/index.ts index 2e5a433a799..b8a837912e1 100644 --- a/e2e/helpers/pages/admin/index.ts +++ b/e2e/helpers/pages/admin/index.ts @@ -7,6 +7,7 @@ export * from './login-verify-page'; export * from './settings'; export * from './whats-new'; export * from './analytics'; +export * from './onboarding'; export * from './posts'; export * from './tags'; export * from './sidebar'; diff --git a/e2e/helpers/pages/admin/onboarding/index.ts b/e2e/helpers/pages/admin/onboarding/index.ts new file mode 100644 index 00000000000..f7449e6951c --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/index.ts @@ -0,0 +1 @@ +export * from './onboarding-page'; diff --git a/e2e/helpers/pages/admin/onboarding/onboarding-page.ts b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts new file mode 100644 index 00000000000..994b550d3da --- /dev/null +++ b/e2e/helpers/pages/admin/onboarding/onboarding-page.ts @@ -0,0 +1,25 @@ +import {AdminPage} from '@/admin-pages'; +import {Locator, Page} from '@playwright/test'; + +export class OnboardingPage extends AdminPage { + public readonly checklist: Locator; + public readonly completeButton: Locator; + public readonly copyLinkButton: Locator; + public readonly shareModal: Locator; + public readonly skipButton: Locator; + + constructor(page: Page) { + super(page); + + this.pageUrl = '/ghost/#/setup/onboarding'; + this.checklist = page.getByTestId('onboarding-checklist'); + this.completeButton = page.getByTestId('onboarding-complete'); + this.copyLinkButton = page.getByTestId('onboarding-copy-link'); + this.shareModal = page.getByTestId('onboarding-share-modal'); + this.skipButton = page.getByTestId('onboarding-skip'); + } + + step(stepId: string) { + return this.page.getByTestId(`onboarding-step-${stepId}`); + } +} diff --git a/e2e/tests/admin/onboarding.test.ts b/e2e/tests/admin/onboarding.test.ts new file mode 100644 index 00000000000..3249c152983 --- /dev/null +++ b/e2e/tests/admin/onboarding.test.ts @@ -0,0 +1,208 @@ +import {AnalyticsOverviewPage, OnboardingPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright'; +import type {Page} from '@playwright/test'; + +type ChecklistState = 'pending' | 'started' | 'completed' | 'dismissed'; + +const allSteps = ['customize-design', 'first-post', 'build-audience', 'share-publication']; +const activeStartedAt = '2026-05-01T00:00:00.000Z'; +const navigationSteps: Array<[string, RegExp]> = [ + ['customize-design', /\/ghost\/#\/settings\/design\/edit\?ref=setup/], + ['first-post', /\/ghost\/#\/editor\/post/], + ['build-audience', /\/ghost\/#\/members/] +]; + +test.use({isolation: 'per-test'}); + +async function getCurrentUser(page: Page) { + const response = await page.request.get('/ghost/api/admin/users/me/?include=roles'); + expect(response.ok()).toBe(true); + + const body = await response.json(); + return body.users[0]; +} + +async function setOnboardingState(page: Page, checklistState: ChecklistState, completedSteps: string[] = [], startedAt: string | null | undefined = checklistState === 'started' ? activeStartedAt : undefined) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + preferences.onboarding = { + completedSteps, + checklistState + }; + + if (startedAt) { + preferences.onboarding.startedAt = startedAt; + } + + const response = await page.request.put(`/ghost/api/admin/users/${user.id}/?include=roles`, { + data: { + users: [{ + ...user, + accessibility: JSON.stringify(preferences) + }] + } + }); + expect(response.ok()).toBe(true); + + await page.reload({waitUntil: 'load'}); +} + +async function getOnboardingPreferences(page: Page) { + const user = await getCurrentUser(page); + const preferences = user.accessibility ? JSON.parse(user.accessibility) : {}; + + return preferences.onboarding; +} + +async function expectOnboardingRoute(page: Page, {returnTo = '/analytics'}: {returnTo?: string} = {}) { + await expect(page).toHaveURL((url) => { + const hashUrl = new URL(url.hash.slice(1), 'http://ghost.local'); + + return hashUrl.pathname === '/setup/onboarding' && hashUrl.searchParams.get('returnTo') === returnTo; + }); +} + +async function startOnboarding(page: Page) { + await setOnboardingState(page, 'started'); + await page.goto(`/ghost/?onboardingTest=${Date.now()}#/setup/onboarding?returnTo=%2Fanalytics`); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); +} + +test.describe('Ghost Admin - Onboarding Checklist', () => { + test('new owner setup flow lands on onboarding', async ({page}) => { + await setOnboardingState(page, 'pending', ['customize-design']); + + await page.goto('/ghost/#/setup/done'); + + const onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'started', + completedSteps: [] + }); + expect(typeof preferences.startedAt).toBe('string'); + }); + + test('analytics routes redirect to onboarding while active', async ({page}) => { + await startOnboarding(page); + + await page.goto('/ghost/#/analytics'); + + let onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page); + + await page.goto('/ghost/#/analytics/web'); + + onboardingPage = new OnboardingPage(page); + await expect(onboardingPage.checklist).toBeVisible(); + await expectOnboardingRoute(page, {returnTo: '/analytics/web'}); + }); + + test('completed and dismissed users reach Analytics normally', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'completed', allSteps); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await setOnboardingState(page, 'dismissed'); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + }); + + test('pending users reach Analytics normally and are not started by the React route', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'pending', ['customize-design']); + await analyticsPage.goto(); + await expect(analyticsPage.header).toBeVisible(); + + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences).toMatchObject({ + checklistState: 'pending', + completedSteps: ['customize-design'] + }); + }); + + test('legacy started users without startedAt reach Analytics and are dismissed', async ({page}) => { + const analyticsPage = new AnalyticsOverviewPage(page); + + await setOnboardingState(page, 'started', [], null); + await analyticsPage.goto(); + + await expect(analyticsPage.header).toBeVisible(); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.checklistState; + }).toBe('dismissed'); + + await expect.poll(async () => { + return (await getOnboardingPreferences(page))?.completedSteps; + }).toEqual([]); + }); + + navigationSteps.forEach(([step, expectedUrl]) => { + test(`${step} step marks complete and navigates`, async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step(step).click(); + + await expect(page).toHaveURL(expectedUrl); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain(step); + }); + }); + + test('share step opens the dialog and marks the step complete', async ({page}) => { + await startOnboarding(page); + + const onboardingPage = new OnboardingPage(page); + await onboardingPage.step('share-publication').click(); + + await expect(onboardingPage.shareModal).toBeVisible(); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.completedSteps).toContain('share-publication'); + }); + + test('skip returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await page.goto('/ghost/#/analytics?source=web'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.skipButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('dismissed'); + }); + + test('completing all steps returns to the preserved analytics URL', async ({page}) => { + await startOnboarding(page); + await setOnboardingState(page, 'started', allSteps); + await page.goto('/ghost/#/setup/onboarding?returnTo=%2Fanalytics%3Fsource%3Dweb'); + + const onboardingPage = new OnboardingPage(page); + await expectOnboardingRoute(page, {returnTo: '/analytics?source=web'}); + await onboardingPage.completeButton.click(); + + await expect(page).toHaveURL(/\/ghost\/#\/analytics\?source=web$/); + + const preferences = await getOnboardingPreferences(page); + expect(preferences.checklistState).toBe('completed'); + }); +}); diff --git a/ghost/admin/app/routes/setup/done.js b/ghost/admin/app/routes/setup/done.js index edeaa025a36..0b4fce120f7 100644 --- a/ghost/admin/app/routes/setup/done.js +++ b/ghost/admin/app/routes/setup/done.js @@ -6,19 +6,19 @@ export default class SetupFinishingTouchesRoute extends AuthenticatedRoute { @inject config; @service feature; @service onboarding; - @service router; @service session; @service settings; - beforeModel() { - super.beforeModel(...arguments); + async beforeModel() { + await super.beforeModel(...arguments); if (this.session.user.isOwnerOnly) { - this.onboarding.startChecklist(); + await this.onboarding.startChecklist(); } if (this.session.user?.isAdmin) { - return this.router.transitionTo('/analytics'); + // The React admin app owns /setup/onboarding, so hand off via hash navigation. + window.location.hash = '/setup/onboarding?returnTo=/analytics'; } } } diff --git a/ghost/admin/app/services/onboarding.js b/ghost/admin/app/services/onboarding.js index 26c8338d82a..951fee10d95 100644 --- a/ghost/admin/app/services/onboarding.js +++ b/ghost/admin/app/services/onboarding.js @@ -3,7 +3,8 @@ import {action} from '@ember/object'; const EMPTY_SETTINGS = { completedSteps: [], - checklistState: 'pending' // pending, started, completed, dismissed + checklistState: 'pending', // pending, started, completed, dismissed + startedAt: undefined }; export default class OnboardingService extends Service { @@ -65,6 +66,7 @@ export default class OnboardingService extends Service { settings.completedSteps = []; settings.checklistState = 'started'; + settings.startedAt = new Date().toISOString(); await this._saveSettings(settings); } diff --git a/ghost/admin/mirage/config/authentication.js b/ghost/admin/mirage/config/authentication.js index c178278474b..61b7a055f06 100644 --- a/ghost/admin/mirage/config/authentication.js +++ b/ghost/admin/mirage/config/authentication.js @@ -60,13 +60,13 @@ export default function mockAuthentication(server) { /* Setup ---------------------------------------------------------------- */ - server.post('/authentication/setup', function ({roles, users}, request) { - let attrs = JSON.parse(request.requestBody).setup; - let role = roles.findBy({name: 'Owner'}); + server.post('/authentication/setup', function (schema, request) { + let [attrs] = JSON.parse(request.requestBody).setup; + let role = schema.roles.findBy({name: 'Owner'}); // create owner role unless already exists if (!role) { - role = roles.create({name: 'Owner'}); + role = schema.roles.create({name: 'Owner'}); } attrs.roles = [role]; @@ -74,8 +74,7 @@ export default function mockAuthentication(server) { attrs.slug = dasherize(attrs.email.split('@')[0]); } - // NOTE: server does not use the user factory to fill in blank fields - return users.create(attrs); + return schema.create('user', attrs); }); server.get('/authentication/setup/', function () { diff --git a/ghost/admin/tests/acceptance/onboarding-test.js b/ghost/admin/tests/acceptance/onboarding-test.js index 0958b9ce2a6..b0d9f87c057 100644 --- a/ghost/admin/tests/acceptance/onboarding-test.js +++ b/ghost/admin/tests/acceptance/onboarding-test.js @@ -1,6 +1,6 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {currentURL, find, visit} from '@ember/test-helpers'; +import {currentURL, find, visit, waitUntil} from '@ember/test-helpers'; import {describe, it} from 'mocha'; import {enableMembers} from '../helpers/members'; import {expect} from 'chai'; @@ -45,6 +45,19 @@ describe('Acceptance: Onboarding', function () { // Onboarding checklist tests removed — checklist is now rendered by // the React analytics app, not Ember. + + it('setup/done starts onboarding and redirects to the React onboarding route', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + let preferences = JSON.parse(user.accessibility); + expect(preferences.onboarding.completedSteps).to.deep.equal([]); + expect(preferences.onboarding.checklistState).to.equal('started'); + expect(preferences.onboarding.startedAt).to.match(/^\d{4}-\d{2}-\d{2}T/); + }); }); describe('checklist (non-owner)', function () { @@ -61,6 +74,16 @@ describe('Acceptance: Onboarding', function () { // onboarding isn't shown expect(checklist()).to.not.exist; }); + + it('setup/done redirects to the React onboarding route without starting onboarding', async function () { + await visit('/setup/done'); + + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); + + let user = this.server.schema.users.first(); + expect(user.accessibility).to.be.null; + }); }); describe('unauthenticated', function () { diff --git a/ghost/admin/tests/acceptance/setup-test.js b/ghost/admin/tests/acceptance/setup-test.js index e8be65a1ed1..4b72f12ee6b 100644 --- a/ghost/admin/tests/acceptance/setup-test.js +++ b/ghost/admin/tests/acceptance/setup-test.js @@ -2,7 +2,7 @@ import {Response} from 'miragejs'; import {afterEach, beforeEach, describe, it} from 'mocha'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; -import {click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; +import {click, currentURL, fillIn, find, findAll, waitUntil} from '@ember/test-helpers'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -32,6 +32,9 @@ describe('Acceptance: Setup', function () { if (!server.schema.settings.all().length) { server.loadFixtures('settings'); } + if (!server.schema.themes.all().length) { + server.loadFixtures('themes'); + } // mimick a new blog server.get('/authentication/setup/', function () { @@ -120,9 +123,10 @@ describe('Acceptance: Setup', function () { await fillIn('[data-test-blog-title-input]', 'Blog Title'); await click('[data-test-button="setup"]'); - // it redirects to the dashboard - expect(currentURL(), 'url after submitting account details') - .to.equal('/analytics'); + // it starts onboarding and hands off to the React onboarding route + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash, 'url after submitting account details') + .to.equal('#/setup/onboarding?returnTo=/analytics'); }); it('handles validation errors in setup', async function () { @@ -214,15 +218,20 @@ describe('Acceptance: Setup', function () { describe('?firstStart=true', function () { beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + this.server.loadFixtures('themes'); + let role = this.server.create('role', {name: 'Owner'}); this.server.create('user', {roles: [role], slug: 'owner'}); await authenticateSession(); }); - it('transitions to dashboard', async function () { + it('transitions to onboarding', async function () { await visit('/?firstStart=true'); - expect(currentURL()).to.equal('/analytics'); + await waitUntil(() => window.location.hash === '#/setup/onboarding?returnTo=/analytics'); + expect(window.location.hash).to.equal('#/setup/onboarding?returnTo=/analytics'); }); }); });