@pradip1995/segment-login-template 0.2.5 → 0.4.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/package.json +9 -2
- package/src/account-addresses.tsx +216 -0
- package/src/account-guest-orders.tsx +60 -0
- package/src/account-orders.tsx +81 -0
- package/src/account-overview.tsx +111 -0
- package/src/account-payment-methods.tsx +229 -0
- package/src/account-profile.tsx +198 -0
- package/src/account-utils.ts +20 -0
- package/src/auth-server.ts +307 -0
- package/src/commerce-auth-otp-modal.tsx +104 -0
- package/src/forgot-password-form.tsx +89 -0
- package/src/google-auth-section.tsx +86 -0
- package/src/index.ts +5 -0
- package/src/login-form.tsx +411 -0
- package/src/otp-component.ts +14 -0
- package/src/otp-input.tsx +46 -0
- package/src/otp-verification-modal.tsx +112 -0
- package/src/register-form.tsx +324 -0
- package/src/reset-password-form.tsx +110 -0
- package/src/reset-password-page.tsx +12 -0
- package/src/segment.tsx +81 -200
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useActionState, useState } from "react"
|
|
4
|
+
import { login } from "@pradip1995/commerce-core/client/actions/customer"
|
|
5
|
+
import { sendAuthOtp, sendOTP, verifyAuthOtpAndLogin, verifyOTP } from "./auth-server"
|
|
6
|
+
import GoogleAuthSection from "./google-auth-section"
|
|
7
|
+
import OtpVerificationModal from "./otp-verification-modal"
|
|
8
|
+
import type { OtpVerificationComponent } from "./otp-component"
|
|
9
|
+
|
|
10
|
+
type LoginMode = "password" | "otp"
|
|
11
|
+
|
|
12
|
+
export default function LoginForm({
|
|
13
|
+
countryCode,
|
|
14
|
+
OtpComponent = OtpVerificationModal,
|
|
15
|
+
onForgot,
|
|
16
|
+
onRegister,
|
|
17
|
+
}: {
|
|
18
|
+
countryCode: string
|
|
19
|
+
OtpComponent?: OtpVerificationComponent
|
|
20
|
+
onForgot: () => void
|
|
21
|
+
onRegister: () => void
|
|
22
|
+
}) {
|
|
23
|
+
const [mode, setMode] = useState<LoginMode>("password")
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-6">
|
|
27
|
+
<GoogleAuthSection countryCode={countryCode} />
|
|
28
|
+
|
|
29
|
+
<div className="relative">
|
|
30
|
+
<div className="absolute inset-0 flex items-center">
|
|
31
|
+
<div className="w-full border-t border-cart-border" />
|
|
32
|
+
</div>
|
|
33
|
+
<div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
|
|
34
|
+
<span className="bg-page-bg px-3 text-muted">Or continue with</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
|
|
39
|
+
<ModeButton active={mode === "password"} onClick={() => setMode("password")}>
|
|
40
|
+
Password
|
|
41
|
+
</ModeButton>
|
|
42
|
+
<ModeButton active={mode === "otp"} onClick={() => setMode("otp")}>
|
|
43
|
+
OTP
|
|
44
|
+
</ModeButton>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{mode === "password" ? (
|
|
48
|
+
<PasswordLoginForm countryCode={countryCode} onForgot={onForgot} />
|
|
49
|
+
) : (
|
|
50
|
+
<OtpLoginForm countryCode={countryCode} OtpComponent={OtpComponent} />
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<p className="text-sm text-center text-muted">
|
|
54
|
+
New here?{" "}
|
|
55
|
+
<button type="button" onClick={onRegister} className="text-brand-accent font-semibold hover:underline">
|
|
56
|
+
Create an account
|
|
57
|
+
</button>
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ModeButton({
|
|
64
|
+
active,
|
|
65
|
+
onClick,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
active: boolean
|
|
69
|
+
onClick: () => void
|
|
70
|
+
children: React.ReactNode
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={onClick}
|
|
76
|
+
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] rounded-md transition-colors ${
|
|
77
|
+
active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
|
|
78
|
+
}`}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function PasswordLoginForm({
|
|
86
|
+
countryCode,
|
|
87
|
+
onForgot,
|
|
88
|
+
}: {
|
|
89
|
+
countryCode: string
|
|
90
|
+
onForgot: () => void
|
|
91
|
+
}) {
|
|
92
|
+
const [state, formAction, pending] = useActionState(login, null)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<form action={formAction} className="space-y-4">
|
|
96
|
+
<input type="hidden" name="country_code" value={countryCode} />
|
|
97
|
+
<Field label="Email or phone" name="email_or_phone" type="text" autoComplete="username" />
|
|
98
|
+
<Field label="Password" name="password" type="password" autoComplete="current-password" />
|
|
99
|
+
{state && typeof state === "string" && state !== "ACCOUNT_DELETION_PENDING" && (
|
|
100
|
+
<p className="text-sm text-brand-sale">
|
|
101
|
+
{state === "Email not verified." || state.includes("not verified")
|
|
102
|
+
? "Your email is not verified yet. Complete OTP verification during registration, or use OTP sign-in below."
|
|
103
|
+
: state}
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
106
|
+
{state === "ACCOUNT_DELETION_PENDING" && (
|
|
107
|
+
<p className="text-sm text-brand-sale">
|
|
108
|
+
This account has a pending deletion request. Check your email to cancel it.
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
<button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
|
|
112
|
+
{pending ? "Signing in…" : "Sign in"}
|
|
113
|
+
</button>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onForgot}
|
|
117
|
+
className="text-sm text-muted hover:text-brand-accent w-full text-center"
|
|
118
|
+
>
|
|
119
|
+
Forgot password?
|
|
120
|
+
</button>
|
|
121
|
+
</form>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function OtpLoginForm({
|
|
126
|
+
countryCode,
|
|
127
|
+
OtpComponent,
|
|
128
|
+
}: {
|
|
129
|
+
countryCode: string
|
|
130
|
+
OtpComponent: OtpVerificationComponent
|
|
131
|
+
}) {
|
|
132
|
+
const [authMethod, setAuthMethod] = useState<"email_auth" | "phone_auth" | "guest">("email_auth")
|
|
133
|
+
const [identifier, setIdentifier] = useState("")
|
|
134
|
+
const [otpToken, setOtpToken] = useState<string | null>(null)
|
|
135
|
+
const [otp, setOtp] = useState("")
|
|
136
|
+
const [showOtpModal, setShowOtpModal] = useState(false)
|
|
137
|
+
const [error, setError] = useState<string | null>(null)
|
|
138
|
+
const [pending, setPending] = useState(false)
|
|
139
|
+
const [isNewUser, setIsNewUser] = useState(false)
|
|
140
|
+
const [firstName, setFirstName] = useState("")
|
|
141
|
+
const [lastName, setLastName] = useState("")
|
|
142
|
+
|
|
143
|
+
async function handleSendOtp(e: React.FormEvent) {
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
setPending(true)
|
|
146
|
+
setError(null)
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (authMethod === "guest") {
|
|
150
|
+
const res = await sendOTP(identifier)
|
|
151
|
+
if (!res.success && (res as { error?: string }).error) {
|
|
152
|
+
throw new Error((res as { error?: string }).error)
|
|
153
|
+
}
|
|
154
|
+
setOtpToken("guest")
|
|
155
|
+
setOtp("")
|
|
156
|
+
setShowOtpModal(true)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const res = await sendAuthOtp(
|
|
161
|
+
authMethod === "email_auth"
|
|
162
|
+
? { email: identifier.trim(), type: "email_auth" }
|
|
163
|
+
: { phone: identifier.replace(/[^\d+]/g, ""), type: "phone_auth" }
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if (!res.success) {
|
|
167
|
+
throw new Error(res.error)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setOtpToken(res.token ?? null)
|
|
171
|
+
setIsNewUser(res.isNewUser ?? false)
|
|
172
|
+
setOtp("")
|
|
173
|
+
setShowOtpModal(true)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
setError(err instanceof Error ? err.message : "Failed to send code")
|
|
176
|
+
} finally {
|
|
177
|
+
setPending(false)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function handleResendOtp() {
|
|
182
|
+
setPending(true)
|
|
183
|
+
setError(null)
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
if (authMethod === "guest") {
|
|
187
|
+
const res = await sendOTP(identifier)
|
|
188
|
+
if (!res.success && (res as { error?: string }).error) {
|
|
189
|
+
throw new Error((res as { error?: string }).error)
|
|
190
|
+
}
|
|
191
|
+
setOtp("")
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const res = await sendAuthOtp(
|
|
196
|
+
authMethod === "email_auth"
|
|
197
|
+
? { email: identifier.trim(), type: "email_auth" }
|
|
198
|
+
: { phone: identifier.replace(/[^\d+]/g, ""), type: "phone_auth" }
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if (!res.success) {
|
|
202
|
+
throw new Error(res.error)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setOtpToken(res.token ?? null)
|
|
206
|
+
setOtp("")
|
|
207
|
+
} catch (err) {
|
|
208
|
+
setError(err instanceof Error ? err.message : "Failed to resend code")
|
|
209
|
+
} finally {
|
|
210
|
+
setPending(false)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function handleVerifyOtp() {
|
|
215
|
+
if (!otp || otp.length < 6) return
|
|
216
|
+
|
|
217
|
+
setPending(true)
|
|
218
|
+
setError(null)
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
if (authMethod === "guest") {
|
|
222
|
+
const res = await verifyOTP(identifier, otpToken || "", otp)
|
|
223
|
+
if (!res.success) {
|
|
224
|
+
throw new Error((res as { error?: string }).error || "Invalid code")
|
|
225
|
+
}
|
|
226
|
+
const token = (res as { token?: string }).token
|
|
227
|
+
if (token) {
|
|
228
|
+
document.cookie = `_medusa_guest_token=${token}; path=/; max-age=86400; SameSite=Lax`
|
|
229
|
+
}
|
|
230
|
+
window.location.href = `/${countryCode}/account/guest-orders`
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (isNewUser && (!firstName.trim() || !lastName.trim())) {
|
|
235
|
+
throw new Error("Please enter your first and last name to finish registration")
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await verifyAuthOtpAndLogin({
|
|
239
|
+
token: otpToken || "",
|
|
240
|
+
code: otp,
|
|
241
|
+
countryCode,
|
|
242
|
+
first_name: isNewUser ? firstName.trim() : undefined,
|
|
243
|
+
last_name: isNewUser ? lastName.trim() : undefined,
|
|
244
|
+
})
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (isNextRedirect(err)) throw err
|
|
247
|
+
setError(err instanceof Error ? err.message : "Verification failed")
|
|
248
|
+
setPending(false)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const otpTitle =
|
|
253
|
+
authMethod === "guest"
|
|
254
|
+
? "Verify guest order access"
|
|
255
|
+
: authMethod === "phone_auth"
|
|
256
|
+
? "Verify your phone"
|
|
257
|
+
: "Verify your email"
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<>
|
|
261
|
+
<form onSubmit={handleSendOtp} className="space-y-4">
|
|
262
|
+
<div className="flex flex-wrap gap-2">
|
|
263
|
+
<Chip active={authMethod === "email_auth"} onClick={() => setAuthMethod("email_auth")}>
|
|
264
|
+
Email OTP
|
|
265
|
+
</Chip>
|
|
266
|
+
<Chip active={authMethod === "phone_auth"} onClick={() => setAuthMethod("phone_auth")}>
|
|
267
|
+
Phone OTP
|
|
268
|
+
</Chip>
|
|
269
|
+
<Chip active={authMethod === "guest"} onClick={() => setAuthMethod("guest")}>
|
|
270
|
+
Guest orders
|
|
271
|
+
</Chip>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<Field
|
|
275
|
+
label={authMethod === "phone_auth" ? "Phone" : "Email"}
|
|
276
|
+
name="identifier"
|
|
277
|
+
type={authMethod === "phone_auth" ? "tel" : "email"}
|
|
278
|
+
value={identifier}
|
|
279
|
+
onChange={(e) => setIdentifier(e.target.value)}
|
|
280
|
+
autoComplete={authMethod === "phone_auth" ? "tel" : "email"}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<p className="text-xs text-muted">
|
|
284
|
+
{authMethod === "guest"
|
|
285
|
+
? "We will send a one-time code to view your guest orders."
|
|
286
|
+
: "We will send a one-time code to sign in or create your account."}
|
|
287
|
+
</p>
|
|
288
|
+
|
|
289
|
+
{error && !showOtpModal && <p className="text-sm text-brand-sale">{error}</p>}
|
|
290
|
+
|
|
291
|
+
<button
|
|
292
|
+
type="submit"
|
|
293
|
+
disabled={pending || !identifier.trim()}
|
|
294
|
+
className="btn-primary w-full disabled:opacity-60"
|
|
295
|
+
>
|
|
296
|
+
{pending ? "Sending…" : "Send verification code"}
|
|
297
|
+
</button>
|
|
298
|
+
</form>
|
|
299
|
+
|
|
300
|
+
<OtpComponent
|
|
301
|
+
open={showOtpModal}
|
|
302
|
+
preventClose
|
|
303
|
+
title={otpTitle}
|
|
304
|
+
description={`Enter the 6-digit code sent to ${identifier || "your contact"}.`}
|
|
305
|
+
otp={otp}
|
|
306
|
+
onOtpChange={setOtp}
|
|
307
|
+
error={error}
|
|
308
|
+
pending={pending}
|
|
309
|
+
verifyLabel="Verify & sign in"
|
|
310
|
+
onVerify={handleVerifyOtp}
|
|
311
|
+
onResend={handleResendOtp}
|
|
312
|
+
extraContent={
|
|
313
|
+
isNewUser && authMethod !== "guest" ? (
|
|
314
|
+
<div className="grid grid-cols-2 gap-3 mb-6">
|
|
315
|
+
<label className="block">
|
|
316
|
+
<span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
|
|
317
|
+
First name
|
|
318
|
+
</span>
|
|
319
|
+
<input
|
|
320
|
+
type="text"
|
|
321
|
+
value={firstName}
|
|
322
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
323
|
+
required
|
|
324
|
+
className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
|
|
325
|
+
/>
|
|
326
|
+
</label>
|
|
327
|
+
<label className="block">
|
|
328
|
+
<span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
|
|
329
|
+
Last name
|
|
330
|
+
</span>
|
|
331
|
+
<input
|
|
332
|
+
type="text"
|
|
333
|
+
value={lastName}
|
|
334
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
335
|
+
required
|
|
336
|
+
className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
|
|
337
|
+
/>
|
|
338
|
+
</label>
|
|
339
|
+
</div>
|
|
340
|
+
) : undefined
|
|
341
|
+
}
|
|
342
|
+
/>
|
|
343
|
+
</>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isNextRedirect(err: unknown) {
|
|
348
|
+
return (
|
|
349
|
+
typeof err === "object" &&
|
|
350
|
+
err !== null &&
|
|
351
|
+
"digest" in err &&
|
|
352
|
+
String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function Chip({
|
|
357
|
+
active,
|
|
358
|
+
onClick,
|
|
359
|
+
children,
|
|
360
|
+
}: {
|
|
361
|
+
active: boolean
|
|
362
|
+
onClick: () => void
|
|
363
|
+
children: React.ReactNode
|
|
364
|
+
}) {
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={onClick}
|
|
369
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
|
|
370
|
+
active
|
|
371
|
+
? "border-brand-accent text-brand-accent bg-brand-accent/5"
|
|
372
|
+
: "border-cart-border text-muted hover:text-heading"
|
|
373
|
+
}`}
|
|
374
|
+
>
|
|
375
|
+
{children}
|
|
376
|
+
</button>
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function Field({
|
|
381
|
+
label,
|
|
382
|
+
name,
|
|
383
|
+
type,
|
|
384
|
+
autoComplete,
|
|
385
|
+
value,
|
|
386
|
+
onChange,
|
|
387
|
+
}: {
|
|
388
|
+
label: string
|
|
389
|
+
name: string
|
|
390
|
+
type: string
|
|
391
|
+
autoComplete?: string
|
|
392
|
+
value?: string
|
|
393
|
+
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
394
|
+
}) {
|
|
395
|
+
return (
|
|
396
|
+
<label className="block">
|
|
397
|
+
<span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
|
|
398
|
+
{label}
|
|
399
|
+
</span>
|
|
400
|
+
<input
|
|
401
|
+
name={name}
|
|
402
|
+
type={type}
|
|
403
|
+
autoComplete={autoComplete}
|
|
404
|
+
value={value}
|
|
405
|
+
onChange={onChange}
|
|
406
|
+
required
|
|
407
|
+
className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
|
|
408
|
+
/>
|
|
409
|
+
</label>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentType } from "react"
|
|
2
|
+
import type { OtpVerificationModalProps } from "./otp-verification-modal"
|
|
3
|
+
import DefaultOtpVerificationModal from "./otp-verification-modal"
|
|
4
|
+
|
|
5
|
+
export type OtpVerificationComponent = ComponentType<OtpVerificationModalProps>
|
|
6
|
+
|
|
7
|
+
export function resolveOtpComponent(
|
|
8
|
+
component?: OtpVerificationComponent | string
|
|
9
|
+
): OtpVerificationComponent {
|
|
10
|
+
if (!component || typeof component === "string") {
|
|
11
|
+
return DefaultOtpVerificationModal
|
|
12
|
+
}
|
|
13
|
+
return component
|
|
14
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
type OtpInputProps = {
|
|
4
|
+
value: string
|
|
5
|
+
onChange: (value: string) => void
|
|
6
|
+
length?: number
|
|
7
|
+
autoFocus?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function OtpInput({
|
|
11
|
+
value,
|
|
12
|
+
onChange,
|
|
13
|
+
length = 6,
|
|
14
|
+
autoFocus = false,
|
|
15
|
+
}: OtpInputProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="relative flex justify-center gap-x-2.5">
|
|
18
|
+
{Array.from({ length }, (_, index) => (
|
|
19
|
+
<div
|
|
20
|
+
key={index}
|
|
21
|
+
className={`w-10 h-12 flex items-center justify-center border-2 rounded-lg text-xl font-bold transition-all duration-200 ${
|
|
22
|
+
value.length === index
|
|
23
|
+
? "border-brand-accent ring-2 ring-brand-accent/10 bg-page-bg"
|
|
24
|
+
: value.length > index
|
|
25
|
+
? "border-cart-border bg-page-bg shadow-sm"
|
|
26
|
+
: "border-cart-border bg-surface-muted/50"
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
{value[index] || ""}
|
|
30
|
+
</div>
|
|
31
|
+
))}
|
|
32
|
+
<input
|
|
33
|
+
type="text"
|
|
34
|
+
inputMode="numeric"
|
|
35
|
+
maxLength={length}
|
|
36
|
+
className="absolute inset-0 opacity-0 cursor-pointer"
|
|
37
|
+
value={value}
|
|
38
|
+
onChange={(event) =>
|
|
39
|
+
onChange(event.target.value.replace(/\D/g, "").slice(0, length))
|
|
40
|
+
}
|
|
41
|
+
autoFocus={autoFocus}
|
|
42
|
+
aria-label="One-time password"
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import OtpInput from "./otp-input"
|
|
4
|
+
|
|
5
|
+
export type OtpVerificationModalProps = {
|
|
6
|
+
open: boolean
|
|
7
|
+
title: string
|
|
8
|
+
description: string
|
|
9
|
+
otp: string
|
|
10
|
+
onOtpChange: (value: string) => void
|
|
11
|
+
error: string | null
|
|
12
|
+
pending: boolean
|
|
13
|
+
success?: boolean
|
|
14
|
+
successTitle?: string
|
|
15
|
+
successMessage?: string
|
|
16
|
+
verifyLabel?: string
|
|
17
|
+
onVerify: () => void
|
|
18
|
+
onResend: () => void
|
|
19
|
+
onClose?: () => void
|
|
20
|
+
/** When true, backdrop click does not dismiss the modal (registration OTP). */
|
|
21
|
+
preventClose?: boolean
|
|
22
|
+
extraContent?: React.ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function OtpVerificationModal({
|
|
26
|
+
open,
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
otp,
|
|
30
|
+
onOtpChange,
|
|
31
|
+
error,
|
|
32
|
+
pending,
|
|
33
|
+
success = false,
|
|
34
|
+
successTitle = "Verified",
|
|
35
|
+
successMessage = "Redirecting you…",
|
|
36
|
+
verifyLabel = "Verify & continue",
|
|
37
|
+
onVerify,
|
|
38
|
+
onResend,
|
|
39
|
+
onClose,
|
|
40
|
+
preventClose = false,
|
|
41
|
+
extraContent,
|
|
42
|
+
}: OtpVerificationModalProps) {
|
|
43
|
+
if (!open) return null
|
|
44
|
+
|
|
45
|
+
function handleBackdropClick() {
|
|
46
|
+
if (preventClose || pending || success) return
|
|
47
|
+
onClose?.()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="absolute inset-0 bg-black/40"
|
|
55
|
+
aria-label="Close verification dialog"
|
|
56
|
+
onClick={handleBackdropClick}
|
|
57
|
+
/>
|
|
58
|
+
<div className="relative w-full max-w-md rounded-2xl bg-page-bg border border-cart-border shadow-xl p-6">
|
|
59
|
+
{success ? (
|
|
60
|
+
<div className="text-center space-y-4 py-4">
|
|
61
|
+
<div className="w-14 h-14 mx-auto rounded-full bg-green-100 flex items-center justify-center text-2xl text-green-600">
|
|
62
|
+
✓
|
|
63
|
+
</div>
|
|
64
|
+
<h2 className="section-heading text-lg">{successTitle}</h2>
|
|
65
|
+
<p className="text-sm text-muted">{successMessage}</p>
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<>
|
|
69
|
+
<h2 className="section-heading text-lg text-center mb-2">{title}</h2>
|
|
70
|
+
<p className="text-sm text-muted text-center mb-6">{description}</p>
|
|
71
|
+
|
|
72
|
+
{extraContent}
|
|
73
|
+
|
|
74
|
+
<div className="mb-6">
|
|
75
|
+
<OtpInput value={otp} onChange={onOtpChange} autoFocus />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{error && (
|
|
79
|
+
<p className="text-sm text-brand-sale text-center mb-4" role="alert">
|
|
80
|
+
{error}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={onVerify}
|
|
87
|
+
disabled={pending || otp.length < 6}
|
|
88
|
+
className="btn-primary w-full disabled:opacity-60 mb-3"
|
|
89
|
+
>
|
|
90
|
+
{pending ? "Verifying…" : verifyLabel}
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={onResend}
|
|
96
|
+
disabled={pending}
|
|
97
|
+
className="text-sm text-muted hover:text-brand-accent w-full text-center disabled:opacity-60"
|
|
98
|
+
>
|
|
99
|
+
Didn't receive the code? Resend
|
|
100
|
+
</button>
|
|
101
|
+
|
|
102
|
+
{preventClose && (
|
|
103
|
+
<p className="text-xs text-muted text-center mt-4">
|
|
104
|
+
Verification is required to complete {verifyLabel.includes("sign in") ? "sign in" : "registration"}.
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|