@exegia/corpora-ui 0.5.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/README.md +21 -0
- package/dist-lib/components/blocks/auth/auth-shell.d.ts +56 -0
- package/dist-lib/components/blocks/auth/code-auth-block.d.ts +22 -0
- package/dist-lib/components/blocks/auth/forgot-password-block.d.ts +15 -0
- package/dist-lib/components/blocks/auth/login-block.d.ts +25 -0
- package/dist-lib/components/blocks/auth/signup-block.d.ts +26 -0
- package/dist-lib/components/composed/password-input.d.ts +30 -0
- package/dist-lib/components/composed/social-providers.d.ts +22 -0
- package/dist-lib/components/docs/code-block.d.ts +4 -0
- package/dist-lib/components/docs/component-preview.d.ts +10 -0
- package/dist-lib/components/docs/demo-controls.d.ts +28 -0
- package/dist-lib/components/docs/props-table.d.ts +4 -0
- package/dist-lib/components/theme-provider.d.ts +15 -0
- package/dist-lib/components/ui/button.d.ts +28 -0
- package/dist-lib/components/ui/card.d.ts +16 -0
- package/dist-lib/components/ui/checkbox.d.ts +7 -0
- package/dist-lib/components/ui/field.d.ts +10 -0
- package/dist-lib/components/ui/frame.d.ts +7 -0
- package/dist-lib/components/ui/glasscn/glass-button.d.ts +6 -0
- package/dist-lib/components/ui/glasscn/liquid-glass.d.ts +31 -0
- package/dist-lib/components/ui/input-group.d.ts +13 -0
- package/dist-lib/components/ui/input.d.ts +9 -0
- package/dist-lib/components/ui/label.d.ts +11 -0
- package/dist-lib/components/ui/otp-field.d.ts +11 -0
- package/dist-lib/components/ui/separator.d.ts +4 -0
- package/dist-lib/components/ui/spinner.d.ts +3 -0
- package/dist-lib/components/ui/textarea.d.ts +8 -0
- package/dist-lib/index.d.ts +29 -0
- package/dist-lib/index.js +1617 -0
- package/dist-lib/index.js.map +1 -0
- package/dist-lib/lib/auth-accent.d.ts +33 -0
- package/dist-lib/lib/glass-variants.d.ts +6 -0
- package/dist-lib/lib/sound.d.ts +13 -0
- package/dist-lib/lib/utils.d.ts +2 -0
- package/dist-lib/lib/utils.test.d.ts +1 -0
- package/package.json +80 -0
- package/src/components/blocks/.gitkeep +0 -0
- package/src/components/blocks/auth/auth-shell.tsx +266 -0
- package/src/components/blocks/auth/code-auth-block.tsx +190 -0
- package/src/components/blocks/auth/forgot-password-block.tsx +123 -0
- package/src/components/blocks/auth/login-block.tsx +239 -0
- package/src/components/blocks/auth/signup-block.tsx +260 -0
- package/src/components/composed/.gitkeep +0 -0
- package/src/components/composed/password-input.tsx +179 -0
- package/src/components/composed/social-providers.tsx +89 -0
- package/src/components/docs/code-block.tsx +8 -0
- package/src/components/docs/component-preview.tsx +28 -0
- package/src/components/docs/demo-controls.tsx +118 -0
- package/src/components/docs/props-table.tsx +35 -0
- package/src/components/theme-provider.tsx +230 -0
- package/src/components/ui/button.tsx +199 -0
- package/src/components/ui/card.tsx +253 -0
- package/src/components/ui/checkbox.tsx +83 -0
- package/src/components/ui/field.tsx +89 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/glasscn/glass-button.tsx +25 -0
- package/src/components/ui/glasscn/liquid-glass.tsx +276 -0
- package/src/components/ui/input-group.tsx +105 -0
- package/src/components/ui/input.tsx +70 -0
- package/src/components/ui/label.tsx +36 -0
- package/src/components/ui/otp-field.tsx +88 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/spinner.tsx +17 -0
- package/src/components/ui/textarea.tsx +58 -0
- package/src/index.css +171 -0
- package/src/index.ts +37 -0
- package/src/lib/auth-accent.ts +49 -0
- package/src/lib/glass-variants.ts +57 -0
- package/src/lib/sound.ts +39 -0
- package/src/lib/utils.test.ts +22 -0
- package/src/lib/utils.ts +6 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardPanel,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from "@/components/ui/card";
|
|
13
|
+
import { Frame, FrameFooter } from "@/components/ui/frame";
|
|
14
|
+
import { Separator } from "@/components/ui/separator";
|
|
15
|
+
import { type AuthAccent, authAccentVars } from "@/lib/auth-accent";
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
17
|
+
|
|
18
|
+
/** transitions.dev motion scale, shared by all auth blocks. */
|
|
19
|
+
export const EASE = [0.22, 1, 0.36, 1] as const;
|
|
20
|
+
|
|
21
|
+
export type AuthStatus = "idle" | "loading" | "success" | "error";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Frame shell shared by the auth blocks (coss particle p-card-8): a raised
|
|
25
|
+
* Card inside a muted Frame, with the footer in the frame strip below the
|
|
26
|
+
* card. Wraps content in a MotionConfig that honors prefers-reduced-motion
|
|
27
|
+
* and layout-animates height changes so step swaps morph instead of jumping.
|
|
28
|
+
*/
|
|
29
|
+
export function AuthCard({
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
children,
|
|
33
|
+
footer,
|
|
34
|
+
className,
|
|
35
|
+
logo,
|
|
36
|
+
accent,
|
|
37
|
+
}: {
|
|
38
|
+
title: React.ReactNode;
|
|
39
|
+
description?: React.ReactNode;
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
footer?: React.ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
44
|
+
logo?: React.ReactNode;
|
|
45
|
+
/** Brand accent for the card's primary action. Omit to keep `bg-primary`. */
|
|
46
|
+
accent?: AuthAccent;
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<MotionConfig reducedMotion="user">
|
|
50
|
+
<Frame
|
|
51
|
+
className={cn("w-full max-w-sm", className)}
|
|
52
|
+
data-accent={accent}
|
|
53
|
+
style={
|
|
54
|
+
accent ? (authAccentVars[accent] as React.CSSProperties) : undefined
|
|
55
|
+
}
|
|
56
|
+
>
|
|
57
|
+
<motion.div
|
|
58
|
+
layout
|
|
59
|
+
transition={{ duration: 0.3, ease: EASE }}
|
|
60
|
+
className="flex flex-col"
|
|
61
|
+
>
|
|
62
|
+
<Card>
|
|
63
|
+
<CardHeader>
|
|
64
|
+
{logo && (
|
|
65
|
+
// Sized like the button's icon rule: a bare img/svg gets a
|
|
66
|
+
// default height, an explicitly sized one keeps its own.
|
|
67
|
+
<div
|
|
68
|
+
className="mb-1.5 flex items-center [&_img:not([class*='h-'])]:h-8 [&_img]:w-auto [&_svg:not([class*='size-'])]:size-8"
|
|
69
|
+
data-slot="auth-logo"
|
|
70
|
+
>
|
|
71
|
+
{logo}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<CardTitle>{title}</CardTitle>
|
|
75
|
+
{description && <CardDescription>{description}</CardDescription>}
|
|
76
|
+
</CardHeader>
|
|
77
|
+
<CardPanel className="overflow-hidden">{children}</CardPanel>
|
|
78
|
+
</Card>
|
|
79
|
+
{footer && (
|
|
80
|
+
<FrameFooter className="text-center text-sm text-muted-foreground">
|
|
81
|
+
{footer}
|
|
82
|
+
</FrameFooter>
|
|
83
|
+
)}
|
|
84
|
+
</motion.div>
|
|
85
|
+
</Frame>
|
|
86
|
+
</MotionConfig>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Cross-morphs between steps (form → success, …) keyed by `step`. */
|
|
91
|
+
export function MorphStep({
|
|
92
|
+
step,
|
|
93
|
+
children,
|
|
94
|
+
className,
|
|
95
|
+
}: {
|
|
96
|
+
step: string;
|
|
97
|
+
children: React.ReactNode;
|
|
98
|
+
className?: string;
|
|
99
|
+
}) {
|
|
100
|
+
return (
|
|
101
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
102
|
+
<motion.div
|
|
103
|
+
key={step}
|
|
104
|
+
className={className}
|
|
105
|
+
initial={{ opacity: 0, y: 8, filter: "blur(2px)" }}
|
|
106
|
+
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
107
|
+
exit={{ opacity: 0, y: -8, filter: "blur(2px)" }}
|
|
108
|
+
transition={{ duration: 0.25, ease: EASE }}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</motion.div>
|
|
112
|
+
</AnimatePresence>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Progressive-disclosure wrapper: collapses/expands a step as `show` flips.
|
|
118
|
+
*
|
|
119
|
+
* `overflow-hidden` is only worn while the height is in flight — leaving it on
|
|
120
|
+
* would clip the focus ring of any full-width control inside (the ring sits
|
|
121
|
+
* 3px outside the input's border box, so the left/right edges would be cut).
|
|
122
|
+
*/
|
|
123
|
+
export function Reveal({
|
|
124
|
+
show,
|
|
125
|
+
children,
|
|
126
|
+
className,
|
|
127
|
+
}: {
|
|
128
|
+
show: boolean;
|
|
129
|
+
children: React.ReactNode;
|
|
130
|
+
className?: string;
|
|
131
|
+
}) {
|
|
132
|
+
// Reset during render (not in an effect) when `show` flips, matching
|
|
133
|
+
// useCountdown below.
|
|
134
|
+
const [state, setState] = React.useState({ show, settled: show });
|
|
135
|
+
if (state.show !== show) setState({ show, settled: false });
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<AnimatePresence initial={false}>
|
|
139
|
+
{show && (
|
|
140
|
+
<motion.div
|
|
141
|
+
className={cn(!state.settled && "overflow-hidden", className)}
|
|
142
|
+
initial={{ height: 0, opacity: 0, y: -4, filter: "blur(2px)" }}
|
|
143
|
+
animate={{ height: "auto", opacity: 1, y: 0, filter: "blur(0px)" }}
|
|
144
|
+
exit={{ height: 0, opacity: 0, y: -4, filter: "blur(2px)" }}
|
|
145
|
+
transition={{ duration: 0.25, ease: EASE }}
|
|
146
|
+
onAnimationComplete={() =>
|
|
147
|
+
setState((current) => ({ ...current, settled: true }))
|
|
148
|
+
}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</motion.div>
|
|
152
|
+
)}
|
|
153
|
+
</AnimatePresence>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Inline error message that shakes each time `message` changes. */
|
|
158
|
+
export function AuthError({ message }: { message: string | null }) {
|
|
159
|
+
return (
|
|
160
|
+
<AnimatePresence initial={false}>
|
|
161
|
+
{message && (
|
|
162
|
+
<motion.p
|
|
163
|
+
key={message}
|
|
164
|
+
role="alert"
|
|
165
|
+
className="text-sm text-destructive-foreground"
|
|
166
|
+
initial={{ opacity: 0, height: 0 }}
|
|
167
|
+
animate={{
|
|
168
|
+
opacity: 1,
|
|
169
|
+
height: "auto",
|
|
170
|
+
x: [0, -6, 6, -4, 4, 0],
|
|
171
|
+
}}
|
|
172
|
+
exit={{ opacity: 0, height: 0 }}
|
|
173
|
+
transition={{
|
|
174
|
+
duration: 0.25,
|
|
175
|
+
ease: EASE,
|
|
176
|
+
x: { duration: 0.4, ease: "easeInOut" },
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
{message}
|
|
180
|
+
</motion.p>
|
|
181
|
+
)}
|
|
182
|
+
</AnimatePresence>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Success panel with a stroke-drawn check. */
|
|
187
|
+
export function AuthSuccess({
|
|
188
|
+
title,
|
|
189
|
+
description,
|
|
190
|
+
children,
|
|
191
|
+
}: {
|
|
192
|
+
title: React.ReactNode;
|
|
193
|
+
description?: React.ReactNode;
|
|
194
|
+
children?: React.ReactNode;
|
|
195
|
+
}) {
|
|
196
|
+
return (
|
|
197
|
+
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
|
198
|
+
<motion.span
|
|
199
|
+
className="flex size-12 items-center justify-center rounded-full bg-emerald-500/12 text-emerald-600"
|
|
200
|
+
initial={{ scale: 0.6, opacity: 0 }}
|
|
201
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
202
|
+
transition={{ duration: 0.4, ease: EASE }}
|
|
203
|
+
>
|
|
204
|
+
<svg
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
viewBox="0 0 24 24"
|
|
207
|
+
className="size-6"
|
|
208
|
+
fill="none"
|
|
209
|
+
stroke="currentColor"
|
|
210
|
+
strokeWidth="2.5"
|
|
211
|
+
strokeLinecap="round"
|
|
212
|
+
strokeLinejoin="round"
|
|
213
|
+
>
|
|
214
|
+
<motion.path
|
|
215
|
+
d="M4 12.5l5 5L20 6.5"
|
|
216
|
+
initial={{ pathLength: 0 }}
|
|
217
|
+
animate={{ pathLength: 1 }}
|
|
218
|
+
transition={{ duration: 0.4, delay: 0.15, ease: EASE }}
|
|
219
|
+
/>
|
|
220
|
+
</svg>
|
|
221
|
+
</motion.span>
|
|
222
|
+
<div className="flex flex-col gap-1">
|
|
223
|
+
<p className="font-medium">{title}</p>
|
|
224
|
+
{description && (
|
|
225
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
{children}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** "Or continue with" style divider. */
|
|
234
|
+
export function AuthSeparator({ label = "Or" }: { label?: string }) {
|
|
235
|
+
return (
|
|
236
|
+
<div className="flex items-center gap-3">
|
|
237
|
+
<Separator className="flex-1" />
|
|
238
|
+
<span className="text-xs text-muted-foreground uppercase">{label}</span>
|
|
239
|
+
<Separator className="flex-1" />
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Countdown for resend links; restarts when `restartKey` changes. */
|
|
245
|
+
export function useCountdown(seconds: number, restartKey: number) {
|
|
246
|
+
const [state, setState] = React.useState({ restartKey, seconds, remaining: seconds });
|
|
247
|
+
|
|
248
|
+
// Reset during render (not in an effect) when the inputs change.
|
|
249
|
+
if (state.restartKey !== restartKey || state.seconds !== seconds) {
|
|
250
|
+
setState({ restartKey, seconds, remaining: seconds });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
React.useEffect(() => {
|
|
254
|
+
if (seconds <= 0) return;
|
|
255
|
+
const interval = window.setInterval(() => {
|
|
256
|
+
setState((current) =>
|
|
257
|
+
current.remaining <= 0
|
|
258
|
+
? current
|
|
259
|
+
: { ...current, remaining: current.remaining - 1 },
|
|
260
|
+
);
|
|
261
|
+
}, 1000);
|
|
262
|
+
return () => window.clearInterval(interval);
|
|
263
|
+
}, [seconds, restartKey]);
|
|
264
|
+
|
|
265
|
+
return state.remaining;
|
|
266
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MailIcon, SmartphoneIcon } from "lucide-react";
|
|
4
|
+
import { motion } from "motion/react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { OTPField, OTPFieldInput } from "@/components/ui/otp-field";
|
|
9
|
+
import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
import {
|
|
12
|
+
AuthCard,
|
|
13
|
+
AuthError,
|
|
14
|
+
AuthSuccess,
|
|
15
|
+
EASE,
|
|
16
|
+
MorphStep,
|
|
17
|
+
useCountdown,
|
|
18
|
+
type AuthStatus,
|
|
19
|
+
} from "./auth-shell";
|
|
20
|
+
|
|
21
|
+
export interface CodeAuthBlockProps {
|
|
22
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
23
|
+
logo?: React.ReactNode;
|
|
24
|
+
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
25
|
+
accent?: AuthAccent;
|
|
26
|
+
/** Where the code was sent; drives copy and icon. */
|
|
27
|
+
channel?: "email" | "sms";
|
|
28
|
+
/** Masked destination shown in the description, e.g. "y•••@example.com". */
|
|
29
|
+
destination?: string;
|
|
30
|
+
length?: number;
|
|
31
|
+
/** Submit automatically once all digits are entered. */
|
|
32
|
+
autoSubmit?: boolean;
|
|
33
|
+
/** Seconds before "Resend code" becomes available. 0 disables the wait. */
|
|
34
|
+
resendSeconds?: number;
|
|
35
|
+
/** Reject (or throw) to show the error shake and clear the code. */
|
|
36
|
+
onVerify?: (code: string) => Promise<void> | void;
|
|
37
|
+
onResend?: () => Promise<void> | void;
|
|
38
|
+
onBack?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CodeAuthBlock({
|
|
42
|
+
channel = "email",
|
|
43
|
+
logo,
|
|
44
|
+
accent,
|
|
45
|
+
destination,
|
|
46
|
+
length = 6,
|
|
47
|
+
autoSubmit = true,
|
|
48
|
+
resendSeconds = 30,
|
|
49
|
+
onVerify,
|
|
50
|
+
onResend,
|
|
51
|
+
onBack,
|
|
52
|
+
}: CodeAuthBlockProps) {
|
|
53
|
+
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
54
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
55
|
+
const [code, setCode] = React.useState("");
|
|
56
|
+
const [resendCount, setResendCount] = React.useState(0);
|
|
57
|
+
const remaining = useCountdown(resendSeconds, resendCount);
|
|
58
|
+
|
|
59
|
+
const ChannelIcon = channel === "sms" ? SmartphoneIcon : MailIcon;
|
|
60
|
+
const channelLabel = channel === "sms" ? "phone" : "email";
|
|
61
|
+
|
|
62
|
+
const verify = React.useCallback(
|
|
63
|
+
async (value: string) => {
|
|
64
|
+
setError(null);
|
|
65
|
+
setStatus("loading");
|
|
66
|
+
try {
|
|
67
|
+
await onVerify?.(value);
|
|
68
|
+
setStatus("success");
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
setStatus("idle");
|
|
71
|
+
setCode("");
|
|
72
|
+
setError(cause instanceof Error ? cause.message : "Invalid code.");
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[onVerify],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function handleChange(value: string) {
|
|
79
|
+
setCode(value);
|
|
80
|
+
if (error) setError(null);
|
|
81
|
+
if (autoSubmit && value.length === length && status === "idle") {
|
|
82
|
+
void verify(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleResend() {
|
|
87
|
+
setResendCount((count) => count + 1);
|
|
88
|
+
setCode("");
|
|
89
|
+
setError(null);
|
|
90
|
+
await onResend?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<AuthCard
|
|
95
|
+
title="Enter verification code"
|
|
96
|
+
logo={logo}
|
|
97
|
+
accent={accent}
|
|
98
|
+
description={
|
|
99
|
+
<span className="inline-flex items-center gap-1.5">
|
|
100
|
+
<ChannelIcon className="size-3.5 shrink-0" />
|
|
101
|
+
<span>
|
|
102
|
+
We sent a {length}-digit code to your {channelLabel}
|
|
103
|
+
{destination ? (
|
|
104
|
+
<>
|
|
105
|
+
{" "}
|
|
106
|
+
<span className="font-medium text-foreground">
|
|
107
|
+
{destination}
|
|
108
|
+
</span>
|
|
109
|
+
</>
|
|
110
|
+
) : null}
|
|
111
|
+
.
|
|
112
|
+
</span>
|
|
113
|
+
</span>
|
|
114
|
+
}
|
|
115
|
+
footer={
|
|
116
|
+
status !== "success" && (
|
|
117
|
+
<Button variant="link" onClick={onBack}>
|
|
118
|
+
Use a different {channelLabel}
|
|
119
|
+
</Button>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
>
|
|
123
|
+
<MorphStep step={status === "success" ? "success" : "form"}>
|
|
124
|
+
{status === "success" ? (
|
|
125
|
+
<AuthSuccess
|
|
126
|
+
title="Verified"
|
|
127
|
+
description="Your identity has been confirmed."
|
|
128
|
+
/>
|
|
129
|
+
) : (
|
|
130
|
+
<div className="flex flex-col items-center gap-4">
|
|
131
|
+
<motion.div
|
|
132
|
+
// Shake the whole field on error; keying by message replays it.
|
|
133
|
+
key={error ?? "steady"}
|
|
134
|
+
animate={error ? { x: [0, -8, 8, -5, 5, 0] } : { x: 0 }}
|
|
135
|
+
transition={{ duration: 0.4, ease: "easeInOut" }}
|
|
136
|
+
>
|
|
137
|
+
<OTPField
|
|
138
|
+
length={length}
|
|
139
|
+
value={code}
|
|
140
|
+
onValueChange={handleChange}
|
|
141
|
+
disabled={status === "loading"}
|
|
142
|
+
>
|
|
143
|
+
{Array.from({ length }, (_, index) => (
|
|
144
|
+
<OTPFieldInput
|
|
145
|
+
key={`slot-${index}`}
|
|
146
|
+
aria-invalid={error ? true : undefined}
|
|
147
|
+
aria-label={`Digit ${index + 1} of ${length}`}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
</OTPField>
|
|
151
|
+
</motion.div>
|
|
152
|
+
<AuthError message={error} />
|
|
153
|
+
{!autoSubmit && (
|
|
154
|
+
<Button
|
|
155
|
+
className={cn("w-full", accent && authAccentActionStyles)}
|
|
156
|
+
loading={status === "loading"}
|
|
157
|
+
disabled={code.length !== length}
|
|
158
|
+
onClick={() => void verify(code)}
|
|
159
|
+
>
|
|
160
|
+
Verify
|
|
161
|
+
</Button>
|
|
162
|
+
)}
|
|
163
|
+
<motion.p
|
|
164
|
+
layout
|
|
165
|
+
transition={{ duration: 0.25, ease: EASE }}
|
|
166
|
+
className="text-center text-sm text-muted-foreground"
|
|
167
|
+
>
|
|
168
|
+
{remaining > 0 ? (
|
|
169
|
+
<>
|
|
170
|
+
Resend code in{" "}
|
|
171
|
+
<span className="font-medium tabular-nums text-foreground">
|
|
172
|
+
{remaining}s
|
|
173
|
+
</span>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<Button
|
|
177
|
+
variant="link"
|
|
178
|
+
disabled={status === "loading"}
|
|
179
|
+
onClick={() => void handleResend()}
|
|
180
|
+
>
|
|
181
|
+
Resend code
|
|
182
|
+
</Button>
|
|
183
|
+
)}
|
|
184
|
+
</motion.p>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</MorphStep>
|
|
188
|
+
</AuthCard>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowLeftIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Field, FieldLabel } from "@/components/ui/field";
|
|
8
|
+
import { Input } from "@/components/ui/input";
|
|
9
|
+
import { type AuthAccent, authAccentActionStyles } from "@/lib/auth-accent";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
import {
|
|
12
|
+
AuthCard,
|
|
13
|
+
AuthError,
|
|
14
|
+
AuthSuccess,
|
|
15
|
+
MorphStep,
|
|
16
|
+
type AuthStatus,
|
|
17
|
+
} from "./auth-shell";
|
|
18
|
+
|
|
19
|
+
export interface ForgotPasswordBlockProps {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
/** Brand mark rendered above the title. Omit for no logo row at all. */
|
|
23
|
+
logo?: React.ReactNode;
|
|
24
|
+
/** Brand accent for the primary action. Omit to keep the default primary. */
|
|
25
|
+
accent?: AuthAccent;
|
|
26
|
+
onSubmit?: (data: { email: string }) => Promise<void> | void;
|
|
27
|
+
onBackToLogin?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ForgotPasswordBlock({
|
|
31
|
+
title = "Reset your password",
|
|
32
|
+
logo,
|
|
33
|
+
accent,
|
|
34
|
+
description = "Enter your email and we will send you a reset link",
|
|
35
|
+
onSubmit,
|
|
36
|
+
onBackToLogin,
|
|
37
|
+
}: ForgotPasswordBlockProps) {
|
|
38
|
+
const emailId = React.useId();
|
|
39
|
+
const [status, setStatus] = React.useState<AuthStatus>("idle");
|
|
40
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
41
|
+
const [email, setEmail] = React.useState("");
|
|
42
|
+
|
|
43
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setError(null);
|
|
46
|
+
setStatus("loading");
|
|
47
|
+
try {
|
|
48
|
+
await onSubmit?.({ email });
|
|
49
|
+
setStatus("success");
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
setStatus("idle");
|
|
52
|
+
setError(
|
|
53
|
+
cause instanceof Error ? cause.message : "Unable to send reset link.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<AuthCard
|
|
60
|
+
title={title}
|
|
61
|
+
description={description}
|
|
62
|
+
logo={logo}
|
|
63
|
+
accent={accent}
|
|
64
|
+
footer={
|
|
65
|
+
<Button
|
|
66
|
+
variant="link"
|
|
67
|
+
className="gap-1"
|
|
68
|
+
onClick={onBackToLogin}
|
|
69
|
+
>
|
|
70
|
+
<ArrowLeftIcon className="size-3.5" />
|
|
71
|
+
Back to login
|
|
72
|
+
</Button>
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
<MorphStep step={status === "success" ? "success" : "form"}>
|
|
76
|
+
{status === "success" ? (
|
|
77
|
+
<AuthSuccess
|
|
78
|
+
title="Check your inbox"
|
|
79
|
+
description={
|
|
80
|
+
<>
|
|
81
|
+
We sent a password reset link to{" "}
|
|
82
|
+
<span className="font-medium text-foreground">{email}</span>.
|
|
83
|
+
</>
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
<Button
|
|
87
|
+
variant="link"
|
|
88
|
+
className="text-xs sm:text-xs"
|
|
89
|
+
onClick={() => setStatus("idle")}
|
|
90
|
+
>
|
|
91
|
+
Use a different email
|
|
92
|
+
</Button>
|
|
93
|
+
</AuthSuccess>
|
|
94
|
+
) : (
|
|
95
|
+
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
96
|
+
<Field name="email">
|
|
97
|
+
<FieldLabel htmlFor={emailId}>Email</FieldLabel>
|
|
98
|
+
<Input
|
|
99
|
+
id={emailId}
|
|
100
|
+
name="email"
|
|
101
|
+
type="email"
|
|
102
|
+
autoComplete="email"
|
|
103
|
+
placeholder="you@example.com"
|
|
104
|
+
required
|
|
105
|
+
disabled={status === "loading"}
|
|
106
|
+
value={email}
|
|
107
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
108
|
+
/>
|
|
109
|
+
</Field>
|
|
110
|
+
<AuthError message={error} />
|
|
111
|
+
<Button
|
|
112
|
+
className={cn("w-full", accent && authAccentActionStyles)}
|
|
113
|
+
type="submit"
|
|
114
|
+
loading={status === "loading"}
|
|
115
|
+
>
|
|
116
|
+
Send reset link
|
|
117
|
+
</Button>
|
|
118
|
+
</form>
|
|
119
|
+
)}
|
|
120
|
+
</MorphStep>
|
|
121
|
+
</AuthCard>
|
|
122
|
+
);
|
|
123
|
+
}
|