Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
};
Comment on lines +13 to +17
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking: I wonder if there is a better way for us to align this with the schema without the typecasts. Maybe we could just inline the defaults below and then parse an empty object to get the default onboarding preferences? 🤔


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" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</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