@exegia/corpora-ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/linked-accounts-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/__tests__/onboarding-block.test.d.ts +1 -0
  3. package/dist-lib/components/blocks/auth/__tests__/passkey-manager-block.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/auth/__tests__/passkey-sign-in-block.test.d.ts +1 -0
  5. package/dist-lib/components/blocks/auth/__tests__/update-password-block.test.d.ts +1 -0
  6. package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
  7. package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
  8. package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
  9. package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
  10. package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
  11. package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
  12. package/dist-lib/components/composed/social-providers.d.ts +7 -0
  13. package/dist-lib/index.d.ts +5 -0
  14. package/dist-lib/index.js +1032 -312
  15. package/dist-lib/index.js.map +1 -1
  16. package/package.json +5 -1
  17. package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
  18. package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
  19. package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
  20. package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
  21. package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
  22. package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
  23. package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
  24. package/src/components/blocks/auth/onboarding-block.tsx +240 -0
  25. package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
  26. package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
  27. package/src/components/blocks/auth/update-password-block.tsx +157 -0
  28. package/src/components/composed/social-providers.tsx +3 -2
  29. package/src/index.ts +5 -0
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import { AnimatePresence, MotionConfig, motion } from "motion/react";
4
+ import * as React from "react";
5
+
6
+ import {
7
+ SOCIAL_PROVIDERS,
8
+ type SocialProvider,
9
+ } from "@/components/composed/social-providers";
10
+ import { Button } from "@/components/ui/button";
11
+ import {
12
+ Card,
13
+ CardDescription,
14
+ CardHeader,
15
+ CardPanel,
16
+ CardTitle,
17
+ } from "@/components/ui/card";
18
+ import { Spinner } from "@/components/ui/spinner";
19
+ import { cn } from "@/lib/utils";
20
+ import { AuthError, EASE } from "./auth-shell";
21
+
22
+ /** One sign-in identity attached to the account. */
23
+ export interface LinkedIdentity {
24
+ id: string;
25
+ provider: SocialProvider;
26
+ /** Account address shown under the provider name, when known. */
27
+ email?: string | null;
28
+ }
29
+
30
+ export interface LinkedAccountsBlockProps {
31
+ title?: string;
32
+ description?: string;
33
+ /** Identities already attached to the account. */
34
+ identities?: LinkedIdentity[];
35
+ /** Providers offered as connect candidates; connected ones are filtered out. */
36
+ providers?: SocialProvider[];
37
+ /** Shows the loading row instead of the list. */
38
+ loading?: boolean;
39
+ /** Reject (or throw) to show the inline error. */
40
+ onLink?: (provider: SocialProvider) => Promise<void> | void;
41
+ onUnlink?: (id: string) => Promise<void> | void;
42
+ className?: string;
43
+ }
44
+
45
+ const LAST_METHOD_EXPLANATION =
46
+ "This is your only way to sign in, so it can't be disconnected.";
47
+
48
+ /**
49
+ * Settings panel for the sign-in identities attached to an account: lists the
50
+ * connected ones, offers connect buttons for the rest, and guards the last
51
+ * remaining method from being disconnected — the guard is a disabled button
52
+ * plus an explanation rather than an error after the fact.
53
+ */
54
+ export function LinkedAccountsBlock({
55
+ title = "Connected accounts",
56
+ description = "Manage the accounts you can use to sign in.",
57
+ identities = [],
58
+ providers = ["google", "apple", "github"],
59
+ loading = false,
60
+ onLink,
61
+ onUnlink,
62
+ className,
63
+ }: LinkedAccountsBlockProps) {
64
+ const noteId = React.useId();
65
+ const [error, setError] = React.useState<string | null>(null);
66
+ const [linking, setLinking] = React.useState<SocialProvider | null>(null);
67
+ const [unlinking, setUnlinking] = React.useState<string | null>(null);
68
+
69
+ const connected = new Set(identities.map((identity) => identity.provider));
70
+ const connectable = providers.filter((provider) => !connected.has(provider));
71
+ const busy = linking !== null || unlinking !== null;
72
+ // The backend rule, enforced here so we never fire a request that would
73
+ // remove the account's only way in.
74
+ const lastMethod = identities.length <= 1;
75
+
76
+ async function connect(provider: SocialProvider) {
77
+ if (busy) return;
78
+ setError(null);
79
+ setLinking(provider);
80
+ try {
81
+ await onLink?.(provider);
82
+ } catch (cause) {
83
+ setError(
84
+ cause instanceof Error ? cause.message : "Unable to connect account.",
85
+ );
86
+ } finally {
87
+ setLinking(null);
88
+ }
89
+ }
90
+
91
+ async function disconnect(id: string) {
92
+ if (busy || lastMethod) return;
93
+ setError(null);
94
+ setUnlinking(id);
95
+ try {
96
+ await onUnlink?.(id);
97
+ } catch (cause) {
98
+ setError(
99
+ cause instanceof Error
100
+ ? cause.message
101
+ : "Unable to disconnect account.",
102
+ );
103
+ } finally {
104
+ setUnlinking(null);
105
+ }
106
+ }
107
+
108
+ return (
109
+ <MotionConfig reducedMotion="user">
110
+ <Card className={cn("w-full", className)} data-slot="auth-block">
111
+ <CardHeader>
112
+ <CardTitle>{title}</CardTitle>
113
+ <CardDescription>{description}</CardDescription>
114
+ </CardHeader>
115
+ <CardPanel>
116
+ {loading ? (
117
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
118
+ <Spinner className="size-4" />
119
+ Loading connected accounts…
120
+ </div>
121
+ ) : (
122
+ <motion.div
123
+ layout
124
+ transition={{ duration: 0.3, ease: EASE }}
125
+ className="flex flex-col gap-3"
126
+ >
127
+ <AuthError message={error} />
128
+
129
+ {identities.length > 0 ? (
130
+ <ul
131
+ aria-label="Connected accounts"
132
+ className="flex flex-col gap-2"
133
+ >
134
+ <AnimatePresence initial={false}>
135
+ {identities.map((identity, index) => {
136
+ const { label, Icon } = SOCIAL_PROVIDERS[
137
+ identity.provider
138
+ ];
139
+ return (
140
+ <motion.li
141
+ key={identity.id}
142
+ layout
143
+ className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2"
144
+ initial={{ opacity: 0, y: -4 }}
145
+ animate={{ opacity: 1, y: 0 }}
146
+ exit={{ opacity: 0, y: -4 }}
147
+ // 40ms per row, capped so a long identity list never
148
+ // leaves the last row arriving noticeably late.
149
+ transition={{
150
+ duration: 0.25,
151
+ ease: EASE,
152
+ delay: Math.min(index, 5) * 0.04,
153
+ }}
154
+ >
155
+ <div className="flex min-w-0 items-center gap-2.5">
156
+ <Icon
157
+ aria-hidden="true"
158
+ className="size-4 text-muted-foreground"
159
+ />
160
+ <div className="flex min-w-0 flex-col">
161
+ <span className="font-medium text-sm">
162
+ {label}
163
+ </span>
164
+ {identity.email && (
165
+ <span className="truncate text-xs text-muted-foreground">
166
+ {identity.email}
167
+ </span>
168
+ )}
169
+ </div>
170
+ </div>
171
+ <Button
172
+ aria-describedby={lastMethod ? noteId : undefined}
173
+ aria-label={`Disconnect ${label}`}
174
+ size="sm"
175
+ type="button"
176
+ variant="destructive-outline"
177
+ disabled={busy || lastMethod}
178
+ loading={unlinking === identity.id}
179
+ onClick={() => void disconnect(identity.id)}
180
+ >
181
+ Disconnect
182
+ </Button>
183
+ </motion.li>
184
+ );
185
+ })}
186
+ </AnimatePresence>
187
+ </ul>
188
+ ) : (
189
+ <p className="text-sm text-muted-foreground">
190
+ No sign-in methods connected yet.
191
+ </p>
192
+ )}
193
+
194
+ {lastMethod && identities.length > 0 && (
195
+ <p className="text-xs text-muted-foreground" id={noteId}>
196
+ {LAST_METHOD_EXPLANATION}
197
+ </p>
198
+ )}
199
+
200
+ {connectable.map((provider) => {
201
+ const { label, Icon } = SOCIAL_PROVIDERS[provider];
202
+ return (
203
+ <Button
204
+ key={provider}
205
+ className="w-full"
206
+ type="button"
207
+ variant="outline"
208
+ disabled={busy}
209
+ loading={linking === provider}
210
+ onClick={() => void connect(provider)}
211
+ >
212
+ <Icon aria-hidden="true" />
213
+ <span className="flex-1">Connect {label}</span>
214
+ </Button>
215
+ );
216
+ })}
217
+ </motion.div>
218
+ )}
219
+ </CardPanel>
220
+ </Card>
221
+ </MotionConfig>
222
+ );
223
+ }
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import { Checkbox } from "@/components/ui/checkbox";
7
+ import { Field, FieldError, FieldLabel } from "@/components/ui/field";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Label } from "@/components/ui/label";
10
+ import { Textarea } from "@/components/ui/textarea";
11
+ import { cn } from "@/lib/utils";
12
+ import type {
13
+ OnboardingFieldConfig,
14
+ OnboardingStepConfig,
15
+ OnboardingValue,
16
+ } from "../onboarding-block";
17
+
18
+ export interface ProfileStepProps {
19
+ step: OnboardingStepConfig;
20
+ /** Seed values, so drafts survive back/forward navigation. */
21
+ values: Record<string, OnboardingValue>;
22
+ submitting?: boolean;
23
+ submitLabel: string;
24
+ onSubmit: (values: Record<string, OnboardingValue>) => void | Promise<void>;
25
+ onBack?: () => void;
26
+ /** Reports every edit so the flow can restore drafts across navigation. */
27
+ onDraftChange?: (name: string, value: OnboardingValue) => void;
28
+ }
29
+
30
+ function initialValue(
31
+ field: OnboardingFieldConfig,
32
+ saved: OnboardingValue | undefined,
33
+ ): OnboardingValue {
34
+ if (field.kind === "checkbox") return typeof saved === "boolean" ? saved : false;
35
+ return typeof saved === "string" ? saved : "";
36
+ }
37
+
38
+ /** Built-in validation, mirroring the declared `FieldConfig` contract. */
39
+ function validateField(
40
+ field: OnboardingFieldConfig,
41
+ value: OnboardingValue,
42
+ ): string | null {
43
+ const required = field.required === true;
44
+
45
+ if (field.kind === "checkbox") {
46
+ if (required && value !== true) return `${field.label} is required`;
47
+ return null;
48
+ }
49
+
50
+ const text = typeof value === "string" ? value.trim() : "";
51
+ if (required && text.length === 0) return `${field.label} is required`;
52
+
53
+ if (field.kind === "url" && text.length > 0) {
54
+ try {
55
+ new URL(text);
56
+ } catch {
57
+ return "Enter a valid URL";
58
+ }
59
+ }
60
+
61
+ if (field.kind === "select" && text.length > 0) {
62
+ const allowed = field.options.map((option) => option.value);
63
+ if (!allowed.includes(text)) {
64
+ return `Choose a valid option for ${field.label}`;
65
+ }
66
+ }
67
+
68
+ return field.validate?.(text) ?? null;
69
+ }
70
+
71
+ /**
72
+ * Renders one declared profile step: `OnboardingFieldConfig[]` → labeled
73
+ * controls, validated locally before anything is handed to `onSubmit`. Mounted
74
+ * keyed by `step.id` so drafts re-seed per step.
75
+ */
76
+ export function ProfileStep({
77
+ step,
78
+ values,
79
+ submitting = false,
80
+ submitLabel,
81
+ onSubmit,
82
+ onBack,
83
+ onDraftChange,
84
+ }: ProfileStepProps) {
85
+ const idPrefix = React.useId();
86
+ const [draft, setDraft] = React.useState<Record<string, OnboardingValue>>(
87
+ () =>
88
+ Object.fromEntries(
89
+ step.fields.map((field) => [
90
+ field.name,
91
+ initialValue(field, values[field.name]),
92
+ ]),
93
+ ),
94
+ );
95
+ const [errors, setErrors] = React.useState<Record<string, string>>({});
96
+
97
+ function setValue(name: string, value: OnboardingValue) {
98
+ setDraft((current) => ({ ...current, [name]: value }));
99
+ onDraftChange?.(name, value);
100
+ // Clear a field's error as soon as it is edited; re-validation happens on
101
+ // the next submit rather than on every keystroke.
102
+ setErrors((current) =>
103
+ current[name] ? { ...current, [name]: "" } : current,
104
+ );
105
+ }
106
+
107
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
108
+ event.preventDefault();
109
+ if (submitting) return;
110
+
111
+ const next: Record<string, string> = {};
112
+ for (const field of step.fields) {
113
+ const message = validateField(field, draft[field.name]);
114
+ if (message) next[field.name] = message;
115
+ }
116
+ if (Object.keys(next).length > 0) {
117
+ setErrors(next);
118
+ return;
119
+ }
120
+ setErrors({});
121
+
122
+ const submitted: Record<string, OnboardingValue> = {};
123
+ for (const field of step.fields) {
124
+ const value = draft[field.name];
125
+ submitted[field.name] = typeof value === "string" ? value.trim() : value;
126
+ }
127
+ await onSubmit(submitted);
128
+ }
129
+
130
+ return (
131
+ <form className="flex flex-col gap-4" noValidate onSubmit={handleSubmit}>
132
+ {step.fields.map((field) => {
133
+ const error = errors[field.name] || undefined;
134
+ const controlId = `${idPrefix}-${field.name}`;
135
+ const value = draft[field.name];
136
+
137
+ if (field.kind === "checkbox") {
138
+ return (
139
+ <Field
140
+ key={field.name}
141
+ name={field.name}
142
+ invalid={Boolean(error)}
143
+ className="gap-1.5"
144
+ >
145
+ <div className="flex items-center gap-2">
146
+ <Checkbox
147
+ id={controlId}
148
+ checked={value === true}
149
+ disabled={submitting}
150
+ onCheckedChange={(checked) => setValue(field.name, checked)}
151
+ />
152
+ <Label htmlFor={controlId} className="font-normal">
153
+ {field.label}
154
+ </Label>
155
+ </div>
156
+ {error && <FieldError match>{error}</FieldError>}
157
+ </Field>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <Field key={field.name} name={field.name} invalid={Boolean(error)}>
163
+ <FieldLabel htmlFor={controlId}>{field.label}</FieldLabel>
164
+ {field.kind === "textarea" ? (
165
+ <Textarea
166
+ id={controlId}
167
+ aria-invalid={error ? true : undefined}
168
+ disabled={submitting}
169
+ placeholder={field.placeholder}
170
+ value={String(value ?? "")}
171
+ onChange={(event) => setValue(field.name, event.target.value)}
172
+ />
173
+ ) : field.kind === "select" ? (
174
+ // No select atom exists in the library yet; a native control
175
+ // styled to match Input keeps the flow self-contained without
176
+ // inventing one here.
177
+ <select
178
+ id={controlId}
179
+ aria-invalid={error ? true : undefined}
180
+ className={cn(
181
+ "h-8.5 w-full rounded-lg border border-input bg-background px-[calc(--spacing(3)-1px)] text-base text-foreground shadow-xs/5 outline-none transition-[box-shadow,border-color] duration-150 ease-smooth-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 disabled:opacity-64 aria-invalid:border-destructive/36 sm:h-7.5 sm:text-sm dark:bg-input/32",
182
+ )}
183
+ disabled={submitting}
184
+ value={String(value ?? "")}
185
+ onChange={(event) => setValue(field.name, event.target.value)}
186
+ >
187
+ <option value="">{field.placeholder ?? "Choose an option"}</option>
188
+ {field.options.map((option) => (
189
+ <option key={option.value} value={option.value}>
190
+ {option.label}
191
+ </option>
192
+ ))}
193
+ </select>
194
+ ) : (
195
+ <Input
196
+ id={controlId}
197
+ aria-invalid={error ? true : undefined}
198
+ disabled={submitting}
199
+ placeholder={field.placeholder}
200
+ type={field.kind === "url" ? "url" : "text"}
201
+ value={String(value ?? "")}
202
+ onChange={(event) => setValue(field.name, event.target.value)}
203
+ />
204
+ )}
205
+ {error && <FieldError match>{error}</FieldError>}
206
+ </Field>
207
+ );
208
+ })}
209
+
210
+ <div className="flex gap-2">
211
+ {onBack && (
212
+ <Button
213
+ type="button"
214
+ variant="outline"
215
+ disabled={submitting}
216
+ onClick={onBack}
217
+ >
218
+ Back
219
+ </Button>
220
+ )}
221
+ <Button className="flex-1" type="submit" loading={submitting}>
222
+ {submitLabel}
223
+ </Button>
224
+ </div>
225
+ </form>
226
+ );
227
+ }
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+ import type { AuthAccent } from "@/lib/auth-accent";
7
+ import {
8
+ AuthCard,
9
+ AuthError,
10
+ AuthSuccess,
11
+ MorphStep,
12
+ type AuthStatus,
13
+ } from "./auth-shell";
14
+ import { ProfileStep } from "./onboarding/profile-step";
15
+
16
+ /** Value a single onboarding field can hold. */
17
+ export type OnboardingValue = string | boolean;
18
+
19
+ export interface OnboardingSelectOption {
20
+ value: string;
21
+ label: string;
22
+ }
23
+
24
+ interface OnboardingFieldBase {
25
+ /** Key the value is collected under; unique within the flow. */
26
+ name: string;
27
+ label: string;
28
+ /** Required fields gate the step's advance. */
29
+ required?: boolean;
30
+ placeholder?: string;
31
+ /** Extra validation; return a message to reject, `null` to accept. */
32
+ validate?: (value: string) => string | null;
33
+ }
34
+
35
+ export interface OnboardingTextField extends OnboardingFieldBase {
36
+ kind: "text" | "textarea" | "url";
37
+ }
38
+
39
+ export interface OnboardingCheckboxField extends OnboardingFieldBase {
40
+ kind: "checkbox";
41
+ }
42
+
43
+ export interface OnboardingSelectField extends OnboardingFieldBase {
44
+ kind: "select";
45
+ options: OnboardingSelectOption[];
46
+ }
47
+
48
+ /** Discriminated on `kind`. */
49
+ export type OnboardingFieldConfig =
50
+ | OnboardingTextField
51
+ | OnboardingCheckboxField
52
+ | OnboardingSelectField;
53
+
54
+ export interface OnboardingStepConfig {
55
+ /** Unique within the flow and stable across releases. */
56
+ id: string;
57
+ title: string;
58
+ description?: string;
59
+ fields: OnboardingFieldConfig[];
60
+ }
61
+
62
+ /** Default configuration: a single required display-name step. */
63
+ export const DEFAULT_ONBOARDING_STEPS: OnboardingStepConfig[] = [
64
+ {
65
+ id: "profile",
66
+ title: "Your profile",
67
+ fields: [
68
+ { kind: "text", name: "display_name", label: "Display name", required: true },
69
+ ],
70
+ },
71
+ ];
72
+
73
+ export interface OnboardingBlockProps {
74
+ /** Declared profile steps. */
75
+ steps?: OnboardingStepConfig[];
76
+ /** Brand mark rendered above the title. Omit for no logo row at all. */
77
+ logo?: React.ReactNode;
78
+ /** Brand accent for the primary action. Omit to keep the default primary. */
79
+ accent?: AuthAccent;
80
+ /**
81
+ * Fires per step as it is submitted. Reject (or throw) to keep the user on
82
+ * the step and show the error.
83
+ */
84
+ onStepSubmit?: (
85
+ stepId: string,
86
+ values: Record<string, OnboardingValue>,
87
+ ) => Promise<void> | void;
88
+ /** Fires once, after the final step is accepted, with the merged profile. */
89
+ onComplete?: (profile: Record<string, OnboardingValue>) => Promise<void> | void;
90
+ /** Shows a brief success screen once onboarding completes. */
91
+ showCompleteScreen?: boolean;
92
+ className?: string;
93
+ }
94
+
95
+ /**
96
+ * Multi-step profile onboarding: a declared `steps` config renders as
97
+ * progress-tracked forms with back/forward navigation, per-step submission and
98
+ * a single completion signal. Drafts survive back-navigation, and the step
99
+ * heading takes focus on advance so a screen reader announces where the flow
100
+ * moved.
101
+ *
102
+ * Account creation and email confirmation are separate blocks (`SignupBlock`,
103
+ * `CodeAuthBlock`) — this one picks up once the account exists.
104
+ */
105
+ export function OnboardingBlock({
106
+ steps = DEFAULT_ONBOARDING_STEPS,
107
+ logo,
108
+ accent,
109
+ onStepSubmit,
110
+ onComplete,
111
+ showCompleteScreen = true,
112
+ className,
113
+ }: OnboardingBlockProps) {
114
+ const headingRef = React.useRef<HTMLHeadingElement>(null);
115
+ // Unsubmitted per-step drafts. Accumulated in a ref so a keystroke does not
116
+ // re-render the whole flow, then published to state at each navigation —
117
+ // the only moment the seed values are actually read.
118
+ const draftsRef = React.useRef<
119
+ Record<string, Record<string, OnboardingValue>>
120
+ >({});
121
+ const [drafts, setDrafts] = React.useState<
122
+ Record<string, Record<string, OnboardingValue>>
123
+ >({});
124
+
125
+ const [index, setIndex] = React.useState(0);
126
+ const [values, setValues] = React.useState<Record<string, OnboardingValue>>(
127
+ {},
128
+ );
129
+ const [status, setStatus] = React.useState<AuthStatus>("idle");
130
+ const [error, setError] = React.useState<string | null>(null);
131
+
132
+ const done = status === "success";
133
+ const step = steps[index];
134
+ const last = index >= steps.length - 1;
135
+
136
+ // Focus the step heading on advance; on error the alert carries the message
137
+ // and stealing focus would talk over it.
138
+ React.useEffect(() => {
139
+ if (error) return;
140
+ headingRef.current?.focus();
141
+ }, [index, done, error]);
142
+
143
+ async function handleStepSubmit(stepValues: Record<string, OnboardingValue>) {
144
+ if (!step) return;
145
+ const merged = { ...values, ...stepValues };
146
+ setError(null);
147
+ setStatus("loading");
148
+ try {
149
+ await onStepSubmit?.(step.id, stepValues);
150
+ if (last) await onComplete?.(merged);
151
+ setValues(merged);
152
+ delete draftsRef.current[step.id];
153
+ setDrafts({ ...draftsRef.current });
154
+ setStatus(last ? "success" : "idle");
155
+ if (!last) setIndex((current) => current + 1);
156
+ } catch (cause) {
157
+ setStatus("idle");
158
+ setError(
159
+ cause instanceof Error ? cause.message : "Unable to save your profile.",
160
+ );
161
+ }
162
+ }
163
+
164
+ if (done && !showCompleteScreen) return null;
165
+
166
+ const heading = done ? "You're all set" : (step?.title ?? "Your profile");
167
+
168
+ return (
169
+ <AuthCard
170
+ title={heading}
171
+ description={done ? undefined : step?.description}
172
+ logo={logo}
173
+ accent={accent}
174
+ className={className}
175
+ >
176
+ <MorphStep step={done ? "done" : `step-${index}`}>
177
+ {done ? (
178
+ <AuthSuccess
179
+ title="Profile saved"
180
+ description="Your account is ready and your profile has been saved."
181
+ />
182
+ ) : step ? (
183
+ <div className="flex flex-col gap-4">
184
+ <nav aria-label="Onboarding progress">
185
+ <ol className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
186
+ {steps.map((item, itemIndex) => (
187
+ <li
188
+ key={item.id}
189
+ aria-current={itemIndex === index ? "step" : undefined}
190
+ className={cn(
191
+ "text-muted-foreground",
192
+ itemIndex === index && "font-medium text-foreground",
193
+ )}
194
+ >
195
+ {item.title}
196
+ {itemIndex < index && (
197
+ <span className="sr-only"> (completed)</span>
198
+ )}
199
+ </li>
200
+ ))}
201
+ </ol>
202
+ </nav>
203
+
204
+ {/* The AuthCard title is the visible heading; this one exists to
205
+ take focus on advance without duplicating it on screen. */}
206
+ <h3 ref={headingRef} tabIndex={-1} className="sr-only">
207
+ {step.title}
208
+ </h3>
209
+
210
+ <AuthError message={error} />
211
+
212
+ <ProfileStep
213
+ key={step.id}
214
+ step={step}
215
+ values={{ ...values, ...drafts[step.id] }}
216
+ submitting={status === "loading"}
217
+ submitLabel={last ? "Finish" : "Continue"}
218
+ onBack={
219
+ index > 0
220
+ ? () => {
221
+ setError(null);
222
+ setDrafts({ ...draftsRef.current });
223
+ setIndex((current) => current - 1);
224
+ }
225
+ : undefined
226
+ }
227
+ onDraftChange={(name, value) => {
228
+ draftsRef.current[step.id] = {
229
+ ...draftsRef.current[step.id],
230
+ [name]: value,
231
+ };
232
+ }}
233
+ onSubmit={handleStepSubmit}
234
+ />
235
+ </div>
236
+ ) : null}
237
+ </MorphStep>
238
+ </AuthCard>
239
+ );
240
+ }