Skip to content

Commit b2ef001

Browse files
committed
feat: Onboarding Welcome Page
1 parent 33d4a5c commit b2ef001

16 files changed

Lines changed: 434 additions & 134 deletions

react-compiler.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [
66
"src/components/Home",
77
"src/components/Editor",
88
"src/components/Learn",
9+
"src/components/Onboarding",
910

1011
// 0 useCallback/useMemo - ready to enable
1112
"src/components/layout",

src/components/Learn/OnboardingHero.tsx

Lines changed: 5 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
import { Link } from "@tanstack/react-router";
2-
1+
import { OnboardingChecklist } from "@/components/Onboarding/OnboardingChecklist";
32
import { Button } from "@/components/ui/button";
43
import { Icon } from "@/components/ui/icon";
54
import { BlockStack, InlineStack } from "@/components/ui/layout";
6-
import { Link as ExternalLink } from "@/components/ui/link";
7-
import { Heading, Paragraph, Text } from "@/components/ui/typography";
8-
import { cn } from "@/lib/utils";
9-
import {
10-
type OnboardingStep,
11-
useOnboarding,
12-
} from "@/providers/OnboardingProvider/OnboardingProvider";
13-
import { DOCUMENTATION_URL } from "@/utils/constants";
14-
import { tracking } from "@/utils/tracking";
5+
import { Heading, Paragraph } from "@/components/ui/typography";
6+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
157

168
function scrollNearestScrollableToTop(el: HTMLElement | null) {
179
let node = el?.parentElement ?? null;
@@ -29,87 +21,8 @@ function scrollNearestScrollableToTop(el: HTMLElement | null) {
2921
window.scrollTo({ top: 0, behavior: "smooth" });
3022
}
3123

32-
function StepCta({
33-
step,
34-
onReadDocs,
35-
}: {
36-
step: OnboardingStep;
37-
onReadDocs: () => void;
38-
}) {
39-
if (step.id === "read_docs") {
40-
return (
41-
<ExternalLink
42-
href={DOCUMENTATION_URL}
43-
external
44-
variant="primary"
45-
size="sm"
46-
onClick={onReadDocs}
47-
{...tracking("learning_hub.onboarding.step", { step_id: step.id })}
48-
>
49-
{step.cta.label}
50-
</ExternalLink>
51-
);
52-
}
53-
54-
return (
55-
<Button
56-
asChild
57-
size="sm"
58-
variant="outline"
59-
{...tracking("learning_hub.onboarding.step", { step_id: step.id })}
60-
>
61-
<Link to={step.cta.to}>{step.cta.label}</Link>
62-
</Button>
63-
);
64-
}
65-
66-
function StepRow({
67-
step,
68-
onReadDocs,
69-
}: {
70-
step: OnboardingStep;
71-
onReadDocs: () => void;
72-
}) {
73-
return (
74-
<InlineStack as="li" gap="3" blockAlign="start" wrap="nowrap">
75-
<Icon
76-
name={step.completed ? "CircleCheck" : step.icon}
77-
size="md"
78-
className={cn(
79-
"mt-0.5 shrink-0",
80-
step.completed ? "text-primary" : "text-muted-foreground",
81-
)}
82-
aria-hidden="true"
83-
/>
84-
<BlockStack className="min-w-0 flex-1">
85-
<Text
86-
size="sm"
87-
weight="semibold"
88-
tone={step.completed ? "subdued" : "inherit"}
89-
className={cn(step.completed && "line-through")}
90-
>
91-
{step.label}
92-
</Text>
93-
<Text size="xs" tone="subdued">
94-
{step.description}
95-
</Text>
96-
</BlockStack>
97-
{!step.completed && <StepCta step={step} onReadDocs={onReadDocs} />}
98-
</InlineStack>
99-
);
100-
}
101-
10224
export function OnboardingHero() {
103-
const {
104-
steps,
105-
completedCount,
106-
total,
107-
isComplete,
108-
markDocsRead,
109-
dismissed,
110-
dismiss,
111-
reopen,
112-
} = useOnboarding();
25+
const { isComplete, dismissed, dismiss, reopen } = useOnboarding();
11326

11427
if (dismissed) {
11528
return (
@@ -164,30 +77,7 @@ export function OnboardingHero() {
16477
</Paragraph>
16578
</BlockStack>
16679

167-
<InlineStack gap="3" blockAlign="center">
168-
<Text size="xs" tone="subdued" weight="semibold">
169-
{completedCount} of {total} steps
170-
</Text>
171-
<div
172-
className="flex-1 h-2 rounded-full bg-muted overflow-hidden"
173-
role="progressbar"
174-
aria-valuenow={completedCount}
175-
aria-valuemin={0}
176-
aria-valuemax={total}
177-
aria-label="Onboarding progress"
178-
>
179-
<div
180-
className="h-full bg-primary transition-all duration-300"
181-
style={{ width: `${(completedCount / total) * 100}%` }}
182-
/>
183-
</div>
184-
</InlineStack>
185-
186-
<BlockStack as="ul" gap="3" className="list-none p-0 m-0">
187-
{steps.map((step) => (
188-
<StepRow key={step.id} step={step} onReadDocs={markDocsRead} />
189-
))}
190-
</BlockStack>
80+
<OnboardingChecklist />
19181
</BlockStack>
19282
</div>
19383
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
import { IndexRedirect } from "./IndexRedirect";
5+
6+
vi.mock("@tanstack/react-router", () => ({
7+
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
8+
}));
9+
10+
let onboarding = { isReady: true, isComplete: false, dismissed: false };
11+
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
12+
useOnboarding: () => onboarding,
13+
}));
14+
15+
afterEach(cleanup);
16+
17+
const target = () => screen.queryByTestId("navigate");
18+
19+
describe("IndexRedirect", () => {
20+
it("waits (no redirect) until onboarding state is ready", () => {
21+
onboarding = { isReady: false, isComplete: false, dismissed: false };
22+
render(<IndexRedirect />);
23+
expect(target()).toBeNull();
24+
});
25+
26+
it("redirects to /welcome while onboarding is active", () => {
27+
onboarding = { isReady: true, isComplete: false, dismissed: false };
28+
render(<IndexRedirect />);
29+
expect(target()).toHaveTextContent("/welcome");
30+
});
31+
32+
it("redirects to /dashboard once complete", () => {
33+
onboarding = { isReady: true, isComplete: true, dismissed: false };
34+
render(<IndexRedirect />);
35+
expect(target()).toHaveTextContent("/dashboard");
36+
});
37+
38+
it("redirects to /dashboard once dismissed", () => {
39+
onboarding = { isReady: true, isComplete: false, dismissed: true };
40+
render(<IndexRedirect />);
41+
expect(target()).toHaveTextContent("/dashboard");
42+
});
43+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Navigate } from "@tanstack/react-router";
2+
3+
import { BlockStack } from "@/components/ui/layout";
4+
import { Spinner } from "@/components/ui/spinner";
5+
import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider";
6+
import { APP_ROUTES } from "@/routes/appRoutes";
7+
8+
export function IndexRedirect() {
9+
const { isReady, isComplete, dismissed } = useOnboarding();
10+
11+
if (!isReady) {
12+
return (
13+
<BlockStack align="center" inlineAlign="center" className="h-full">
14+
<Spinner />
15+
</BlockStack>
16+
);
17+
}
18+
19+
const showOnboarding = !isComplete && !dismissed;
20+
return (
21+
<Navigate
22+
replace
23+
to={showOnboarding ? APP_ROUTES.WELCOME : APP_ROUTES.DASHBOARD}
24+
/>
25+
);
26+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Link } from "@tanstack/react-router";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Icon } from "@/components/ui/icon";
5+
import { BlockStack, InlineStack } from "@/components/ui/layout";
6+
import { Link as ExternalLink } from "@/components/ui/link";
7+
import { Text } from "@/components/ui/typography";
8+
import { cn } from "@/lib/utils";
9+
import {
10+
type OnboardingStep,
11+
useOnboarding,
12+
} from "@/providers/OnboardingProvider/OnboardingProvider";
13+
import { DOCUMENTATION_URL } from "@/utils/constants";
14+
import { tracking } from "@/utils/tracking";
15+
16+
function StepCta({
17+
step,
18+
onReadDocs,
19+
}: {
20+
step: OnboardingStep;
21+
onReadDocs: () => void;
22+
}) {
23+
if (step.id === "read_docs") {
24+
return (
25+
<ExternalLink
26+
href={DOCUMENTATION_URL}
27+
external
28+
variant="primary"
29+
size="sm"
30+
onClick={onReadDocs}
31+
{...tracking("learning_hub.onboarding.step", { step_id: step.id })}
32+
>
33+
{step.cta.label}
34+
</ExternalLink>
35+
);
36+
}
37+
38+
return (
39+
<Button
40+
asChild
41+
size="sm"
42+
variant="outline"
43+
{...tracking("learning_hub.onboarding.step", { step_id: step.id })}
44+
>
45+
<Link to={step.cta.to}>{step.cta.label}</Link>
46+
</Button>
47+
);
48+
}
49+
50+
function StepRow({
51+
step,
52+
onReadDocs,
53+
}: {
54+
step: OnboardingStep;
55+
onReadDocs: () => void;
56+
}) {
57+
return (
58+
<InlineStack as="li" gap="3" blockAlign="start" wrap="nowrap">
59+
<Icon
60+
name={step.completed ? "CircleCheck" : step.icon}
61+
size="md"
62+
className={cn(
63+
"mt-0.5 shrink-0",
64+
step.completed ? "text-primary" : "text-muted-foreground",
65+
)}
66+
aria-hidden="true"
67+
/>
68+
<BlockStack className="min-w-0 flex-1">
69+
<Text
70+
size="sm"
71+
weight="semibold"
72+
tone={step.completed ? "subdued" : "inherit"}
73+
className={cn(step.completed && "line-through")}
74+
>
75+
{step.label}
76+
</Text>
77+
<Text size="xs" tone="subdued">
78+
{step.description}
79+
</Text>
80+
</BlockStack>
81+
{!step.completed && <StepCta step={step} onReadDocs={onReadDocs} />}
82+
</InlineStack>
83+
);
84+
}
85+
86+
export function OnboardingChecklist() {
87+
const { steps, completedCount, total, markDocsRead } = useOnboarding();
88+
89+
return (
90+
<BlockStack gap="4">
91+
<InlineStack gap="3" blockAlign="center">
92+
<Text size="xs" tone="subdued" weight="semibold">
93+
{completedCount} of {total} steps
94+
</Text>
95+
<div
96+
className="flex-1 h-2 rounded-full bg-muted overflow-hidden"
97+
role="progressbar"
98+
aria-valuenow={completedCount}
99+
aria-valuemin={0}
100+
aria-valuemax={total}
101+
aria-label="Onboarding progress"
102+
>
103+
<div
104+
className="h-full bg-primary transition-all duration-300"
105+
style={{ width: `${(completedCount / total) * 100}%` }}
106+
/>
107+
</div>
108+
</InlineStack>
109+
110+
<BlockStack as="ul" gap="3" align="stretch" className="list-none p-0 m-0">
111+
{steps.map((step) => (
112+
<StepRow key={step.id} step={step} onReadDocs={markDocsRead} />
113+
))}
114+
</BlockStack>
115+
</BlockStack>
116+
);
117+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import type { ReactNode } from "react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import { OnboardingNavPill } from "./OnboardingNavPill";
6+
7+
vi.mock("@tanstack/react-router", () => ({
8+
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
9+
}));
10+
11+
let onboarding = {
12+
steps: [],
13+
completedCount: 1,
14+
total: 4,
15+
isComplete: false,
16+
dismissed: false,
17+
isResolved: true,
18+
markDocsRead: vi.fn(),
19+
};
20+
vi.mock("@/providers/OnboardingProvider/OnboardingProvider", () => ({
21+
useOnboarding: () => onboarding,
22+
}));
23+
24+
function resetState() {
25+
onboarding = {
26+
steps: [],
27+
completedCount: 1,
28+
total: 4,
29+
isComplete: false,
30+
dismissed: false,
31+
isResolved: true,
32+
markDocsRead: vi.fn(),
33+
};
34+
}
35+
36+
beforeEach(resetState);
37+
afterEach(cleanup);
38+
39+
const pill = () => screen.queryByText(/Onboarding/);
40+
41+
describe("OnboardingNavPill", () => {
42+
it("shows progress while onboarding is in progress", () => {
43+
render(<OnboardingNavPill />);
44+
expect(pill()).toHaveTextContent("Onboarding · 1/4");
45+
});
46+
47+
it("is hidden once onboarding is complete", () => {
48+
onboarding.isComplete = true;
49+
render(<OnboardingNavPill />);
50+
expect(pill()).toBeNull();
51+
});
52+
53+
it("is hidden once onboarding is dismissed", () => {
54+
onboarding.dismissed = true;
55+
render(<OnboardingNavPill />);
56+
expect(pill()).toBeNull();
57+
});
58+
});

0 commit comments

Comments
 (0)