@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.
- package/dist-lib/components/blocks/auth/__tests__/linked-accounts-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/onboarding-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-manager-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/passkey-sign-in-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/__tests__/update-password-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/linked-accounts-block.d.ts +30 -0
- package/dist-lib/components/blocks/auth/onboarding/profile-step.d.ts +19 -0
- package/dist-lib/components/blocks/auth/onboarding-block.d.ts +69 -0
- package/dist-lib/components/blocks/auth/passkey-manager-block.d.ts +38 -0
- package/dist-lib/components/blocks/auth/passkey-sign-in-block.d.ts +27 -0
- package/dist-lib/components/blocks/auth/update-password-block.d.ts +28 -0
- package/dist-lib/components/composed/social-providers.d.ts +7 -0
- package/dist-lib/index.d.ts +5 -0
- package/dist-lib/index.js +1032 -312
- package/dist-lib/index.js.map +1 -1
- package/package.json +5 -1
- package/src/components/blocks/auth/__tests__/linked-accounts-block.test.tsx +110 -0
- package/src/components/blocks/auth/__tests__/onboarding-block.test.tsx +156 -0
- package/src/components/blocks/auth/__tests__/passkey-manager-block.test.tsx +131 -0
- package/src/components/blocks/auth/__tests__/passkey-sign-in-block.test.tsx +59 -0
- package/src/components/blocks/auth/__tests__/update-password-block.test.tsx +95 -0
- package/src/components/blocks/auth/linked-accounts-block.tsx +223 -0
- package/src/components/blocks/auth/onboarding/profile-step.tsx +227 -0
- package/src/components/blocks/auth/onboarding-block.tsx +240 -0
- package/src/components/blocks/auth/passkey-manager-block.tsx +334 -0
- package/src/components/blocks/auth/passkey-sign-in-block.tsx +81 -0
- package/src/components/blocks/auth/update-password-block.tsx +157 -0
- package/src/components/composed/social-providers.tsx +3 -2
- 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
|
+
}
|