Skip to content
37 changes: 36 additions & 1 deletion apps/admin/src/hooks/user-preferences.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +30,7 @@ const fixtures = {
},
defaults: {
navigation: DEFAULT_NAVIGATION_PREFERENCES,
onboarding: DEFAULT_ONBOARDING_PREFERENCES,
}
};

Expand Down Expand Up @@ -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",
},
Expand All @@ -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, () => {
Expand Down Expand Up @@ -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"),
},
Expand Down Expand Up @@ -349,6 +374,7 @@ describe("useUserPreferences", () => {
posts: false,
},
},
onboarding: DEFAULT_ONBOARDING_PREFERENCES,
whatsNew: {
lastSeenDate: new Date("2025-01-01T00:00:00.000Z"),
},
Expand Down Expand Up @@ -423,13 +449,18 @@ describe("useEditUserPreferences", () => {
expanded: { posts: false, members: false },
menu: { visible: true },
},
onboarding: {
completedSteps: ["customize-design"],
checklistState: "started",
},
nightShift: true,
}),
});

await act(async () => {
await mutation.current.mutateAsync({
navigation: { expanded: { posts: true } },
onboarding: { completedSteps: ["customize-design", "first-post"] },
});
});

Expand All @@ -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
});
});
Expand Down
15 changes: 14 additions & 1 deletion apps/admin/src/hooks/user-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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<typeof PreferencesSchema>;
export type WhatsNewPreferences = z.infer<typeof WhatsNewPreferencesSchema>;
export type OnboardingPreferences = z.infer<typeof OnboardingPreferencesSchema>;
export type NavigationPreferences = z.infer<typeof NavigationPreferencesSchema>;

const userPreferencesQueryKey = (user: User | undefined) => ["userPreferences", user?.id, user?.accessibility] as const;
Expand Down Expand Up @@ -80,7 +94,6 @@ export const useEditUserPreferences = (): UseMutationResult<void, Error, DeepPar

const currentPreferences = queryClient.getQueryData<Preferences>(userPreferencesQueryKey(user)) ?? PreferencesSchema.parse({});

// TODO: use zod to validate?
const newPreferences = deepMerge(currentPreferences, updatedPreferences);

const encodedForStorage = PreferencesSchema.encode(newPreferences);
Expand Down
102 changes: 102 additions & 0 deletions apps/admin/src/onboarding/components/onboarding-checklist.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="relative flex min-h-screen flex-col items-center justify-center px-6 py-8">
<section className="mt-[-48px] flex w-full flex-col items-center" data-test-dashboard="onboarding-checklist" data-testid="onboarding-checklist">
<div className="mb-8 flex flex-col items-center text-center">
<OnboardingLogoVideo />
{allStepsCompleted ?
<h1 className="text-[32px] leading-[1.15] font-bold tracking-normal text-foreground max-[480px]:text-[24px]">You&apos;re all set.</h1>
:
<>
<h1 className="text-[32px] leading-[1.15] font-bold tracking-normal text-foreground max-[480px]:text-[24px]">Let&apos;s get started!</h1>
<p className="mt-2 mb-0 text-[15px] text-muted-foreground max-[480px]:m-0 max-[480px]:text-[14px]">Welcome! It&apos;s time to set up {siteTitle}.</p>
</>
}
</div>

<div className="w-full max-w-[540px] rounded-md border border-border bg-background px-6 pt-4 pb-1">
<div className={`mt-[-12px] flex items-center justify-between py-6 ${nextStep === "customize-design" ? "border-b-0" : "border-b border-border"}`}>
<span className="flex min-w-0 items-center opacity-20">
<LucideIcon.Rocket className="mr-4 size-5 shrink-0 text-purple" />
<span className="truncate pr-8 text-[16px] leading-[1.3] font-bold text-foreground">Start a new Ghost publication</span>
</span>
<span className="shrink-0 text-green">
<LucideIcon.Check className="size-5" />
</span>
</div>

{ONBOARDING_STEPS.map((step, index) => (
<OnboardingStepItem
key={step.id}
complete={completedStepSet.has(step.id)}
id={`ob-${step.id}`}
isBeforeNext={ONBOARDING_STEPS[index + 1]?.id === nextStep}
isLast={index === ONBOARDING_STEPS.length - 1}
isNext={nextStep === step.id}
step={step}
onClick={() => onStepClick(step.id)}
/>
))}
</div>

{allStepsCompleted &&
<Button
className="mt-6 h-auto w-full max-w-[540px] px-3 py-3 text-[16px] max-[480px]:text-[15px]"
data-testid="onboarding-complete"
id="ob-completed"
type="button"
onClick={onComplete}
>
Explore your dashboard
</Button>
}

<p className="mt-8 mb-0 text-[15px] text-muted-foreground max-[480px]:text-[14px]">
More questions? Check out our{" "}
<Button asChild className="h-auto p-0 align-baseline text-[15px] text-green hover:text-green/90 max-[480px]:text-[14px]" variant="link">
<a href="https://ghost.org/help?utm_source=admin&utm_campaign=onboarding" id="ob-help-center" rel="noreferrer" target="_blank">Help Center</a>
</Button>.
</p>

{!allStepsCompleted &&
<Button
className="mt-6 h-[38px] overflow-hidden px-3.5 py-px text-[15px] font-normal whitespace-nowrap text-muted-foreground hover:border-gray-300 hover:text-foreground"
data-testid="onboarding-skip"
id="ob-skip"
type="button"
variant="outline"
onClick={onDismiss}
>
Skip onboarding
</Button>
}
</section>
</main>
);
}
40 changes: 40 additions & 0 deletions apps/admin/src/onboarding/components/onboarding-logo-video.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative mb-6 size-20">
<video
aria-hidden="true"
autoPlay
className="size-20 dark:hidden"
height={80}
loop
muted
playsInline
preload="metadata"
role="presentation"
tabIndex={-1}
width={80}
>
<source src={logoLoaderUrl} type="video/mp4" />
</video>
<video
aria-hidden="true"
autoPlay
className="hidden size-20 dark:block"
height={80}
loop
muted
playsInline
preload="metadata"
role="presentation"
tabIndex={-1}
width={80}
>
<source src={logoLoaderDarkUrl} type="video/mp4" />
</video>
<div className="pointer-events-none absolute inset-0 hidden bg-[hsl(216deg_11%_70%/1%)] dark:block" />
</div>
);
}
51 changes: 51 additions & 0 deletions apps/admin/src/onboarding/components/onboarding-step-item.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
className={`group ${rowClassName}`}
data-testid={`onboarding-step-${step.id}`}
id={id}
type="button"
onClick={onClick}
>
<span className={`flex min-w-0 items-center ${complete ? "opacity-20 group-hover:opacity-25" : "group-hover:opacity-90"}`}>
<Icon className="mr-4 size-5 shrink-0 text-purple" />
<span className="min-w-0 text-left">
<span className="block truncate pr-8 text-[16px] leading-[1.3] font-bold text-foreground">{step.title}</span>
{isNext &&
<span className="mt-1 block pr-8 text-[15px] leading-[1.4] text-muted-foreground">{step.description}</span>
}
</span>
</span>
<span className={`flex shrink-0 items-center transition-transform group-hover:translate-x-[5px] ${complete ? "text-green" : "text-purple"}`}>
{complete ? <LucideIcon.Check className="size-5" /> : <LucideIcon.ArrowRight className="size-3.5" />}
</span>
</button>
);
}
Loading
Loading