@pradip1995/segment-login-template 0.2.4 → 0.3.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 +8 -4
- package/src/account-overview.tsx +69 -0
- package/src/auth-server.ts +152 -0
- package/src/forgot-password-form.tsx +89 -0
- package/src/google-auth-section.tsx +25 -0
- package/src/index.ts +1 -0
- package/src/login-form.tsx +355 -0
- package/src/otp-input.tsx +46 -0
- package/src/register-form.tsx +298 -0
- package/src/reset-password-form.tsx +110 -0
- package/src/reset-password-page.tsx +12 -0
- package/src/segment.tsx +19 -196
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useActionState, useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { login } from "@pradip1995/commerce-core/client/actions/customer"
|
|
6
|
+
import { sendAuthOtp, sendOTP, verifyAuthOtpAndLogin, verifyOTP } from "./auth-server"
|
|
7
|
+
import OtpInput from "./otp-input"
|
|
8
|
+
import GoogleAuthSection from "./google-auth-section"
|
|
9
|
+
|
|
10
|
+
type LoginMode = "password" | "otp"
|
|
11
|
+
|
|
12
|
+
export default function LoginForm({
|
|
13
|
+
countryCode,
|
|
14
|
+
onForgot,
|
|
15
|
+
onRegister,
|
|
16
|
+
}: {
|
|
17
|
+
countryCode: string
|
|
18
|
+
onForgot: () => void
|
|
19
|
+
onRegister: () => void
|
|
20
|
+
}) {
|
|
21
|
+
const [mode, setMode] = useState<LoginMode>("password")
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="space-y-6">
|
|
25
|
+
<GoogleAuthSection countryCode={countryCode} />
|
|
26
|
+
|
|
27
|
+
<div className="relative">
|
|
28
|
+
<div className="absolute inset-0 flex items-center">
|
|
29
|
+
<div className="w-full border-t border-cart-border" />
|
|
30
|
+
</div>
|
|
31
|
+
<div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
|
|
32
|
+
<span className="bg-page-bg px-3 text-muted">Or continue with</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
|
|
37
|
+
<ModeButton active={mode === "password"} onClick={() => setMode("password")}>
|
|
38
|
+
Password
|
|
39
|
+
</ModeButton>
|
|
40
|
+
<ModeButton active={mode === "otp"} onClick={() => setMode("otp")}>
|
|
41
|
+
OTP
|
|
42
|
+
</ModeButton>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{mode === "password" ? (
|
|
46
|
+
<PasswordLoginForm countryCode={countryCode} onForgot={onForgot} />
|
|
47
|
+
) : (
|
|
48
|
+
<OtpLoginForm countryCode={countryCode} />
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
<p className="text-sm text-center text-muted">
|
|
52
|
+
New here?{" "}
|
|
53
|
+
<button type="button" onClick={onRegister} className="text-brand-accent font-semibold hover:underline">
|
|
54
|
+
Create an account
|
|
55
|
+
</button>
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ModeButton({
|
|
62
|
+
active,
|
|
63
|
+
onClick,
|
|
64
|
+
children,
|
|
65
|
+
}: {
|
|
66
|
+
active: boolean
|
|
67
|
+
onClick: () => void
|
|
68
|
+
children: React.ReactNode
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] rounded-md transition-colors ${
|
|
75
|
+
active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</button>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function PasswordLoginForm({
|
|
84
|
+
countryCode,
|
|
85
|
+
onForgot,
|
|
86
|
+
}: {
|
|
87
|
+
countryCode: string
|
|
88
|
+
onForgot: () => void
|
|
89
|
+
}) {
|
|
90
|
+
const [state, formAction, pending] = useActionState(login, null)
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form action={formAction} className="space-y-4">
|
|
94
|
+
<input type="hidden" name="country_code" value={countryCode} />
|
|
95
|
+
<Field label="Email or phone" name="email_or_phone" type="text" autoComplete="username" />
|
|
96
|
+
<Field label="Password" name="password" type="password" autoComplete="current-password" />
|
|
97
|
+
{state && typeof state === "string" && state !== "ACCOUNT_DELETION_PENDING" && (
|
|
98
|
+
<p className="text-sm text-brand-sale">{state}</p>
|
|
99
|
+
)}
|
|
100
|
+
{state === "ACCOUNT_DELETION_PENDING" && (
|
|
101
|
+
<p className="text-sm text-brand-sale">
|
|
102
|
+
This account has a pending deletion request. Check your email to cancel it.
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
<button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
|
|
106
|
+
{pending ? "Signing in…" : "Sign in"}
|
|
107
|
+
</button>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={onForgot}
|
|
111
|
+
className="text-sm text-muted hover:text-brand-accent w-full text-center"
|
|
112
|
+
>
|
|
113
|
+
Forgot password?
|
|
114
|
+
</button>
|
|
115
|
+
</form>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function OtpLoginForm({ countryCode }: { countryCode: string }) {
|
|
120
|
+
const router = useRouter()
|
|
121
|
+
const [authMethod, setAuthMethod] = useState<"email_auth" | "phone_auth" | "guest">("email_auth")
|
|
122
|
+
const [identifier, setIdentifier] = useState("")
|
|
123
|
+
const [otpToken, setOtpToken] = useState<string | null>(null)
|
|
124
|
+
const [otp, setOtp] = useState("")
|
|
125
|
+
const [step, setStep] = useState<"identifier" | "otp">("identifier")
|
|
126
|
+
const [error, setError] = useState<string | null>(null)
|
|
127
|
+
const [pending, setPending] = useState(false)
|
|
128
|
+
const [isNewUser, setIsNewUser] = useState(false)
|
|
129
|
+
const [firstName, setFirstName] = useState("")
|
|
130
|
+
const [lastName, setLastName] = useState("")
|
|
131
|
+
|
|
132
|
+
async function handleSendOtp(e: React.FormEvent) {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
setPending(true)
|
|
135
|
+
setError(null)
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (authMethod === "guest") {
|
|
139
|
+
const res = await sendOTP(identifier)
|
|
140
|
+
if (!res.success && (res as { error?: string }).error) {
|
|
141
|
+
throw new Error((res as { error?: string }).error)
|
|
142
|
+
}
|
|
143
|
+
setOtpToken("guest")
|
|
144
|
+
setStep("otp")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const res = await sendAuthOtp(
|
|
149
|
+
authMethod === "email_auth"
|
|
150
|
+
? { email: identifier, type: "email_auth" }
|
|
151
|
+
: { phone: identifier, type: "phone_auth" }
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if (!res.success) {
|
|
155
|
+
throw new Error(res.error)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setOtpToken(res.token ?? null)
|
|
159
|
+
setIsNewUser(res.isNewUser ?? false)
|
|
160
|
+
setStep("otp")
|
|
161
|
+
} catch (err) {
|
|
162
|
+
setError(err instanceof Error ? err.message : "Failed to send code")
|
|
163
|
+
} finally {
|
|
164
|
+
setPending(false)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function handleVerifyOtp(e: React.FormEvent) {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
if (!otp || otp.length < 6) return
|
|
171
|
+
|
|
172
|
+
setPending(true)
|
|
173
|
+
setError(null)
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (authMethod === "guest") {
|
|
177
|
+
const res = await verifyOTP(identifier, otpToken || "", otp)
|
|
178
|
+
if (!res.success) {
|
|
179
|
+
throw new Error((res as { error?: string }).error || "Invalid code")
|
|
180
|
+
}
|
|
181
|
+
const token = (res as { token?: string }).token
|
|
182
|
+
if (token) {
|
|
183
|
+
document.cookie = `_medusa_guest_token=${token}; path=/; max-age=86400; SameSite=Lax`
|
|
184
|
+
}
|
|
185
|
+
router.push(`/${countryCode}/account/guest-orders`)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isNewUser && (!firstName.trim() || !lastName.trim())) {
|
|
190
|
+
throw new Error("Please enter your first and last name to finish registration")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await verifyAuthOtpAndLogin({
|
|
194
|
+
token: otpToken || "",
|
|
195
|
+
code: otp,
|
|
196
|
+
countryCode,
|
|
197
|
+
first_name: isNewUser ? firstName.trim() : undefined,
|
|
198
|
+
last_name: isNewUser ? lastName.trim() : undefined,
|
|
199
|
+
})
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (isNextRedirect(err)) throw err
|
|
202
|
+
setError(err instanceof Error ? err.message : "Verification failed")
|
|
203
|
+
setPending(false)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (step === "otp") {
|
|
208
|
+
return (
|
|
209
|
+
<form onSubmit={handleVerifyOtp} className="space-y-4">
|
|
210
|
+
<p className="text-sm text-muted text-center">
|
|
211
|
+
Enter the 6-digit code sent to <span className="font-medium text-heading">{identifier}</span>
|
|
212
|
+
</p>
|
|
213
|
+
|
|
214
|
+
{isNewUser && authMethod !== "guest" && (
|
|
215
|
+
<div className="grid grid-cols-2 gap-3">
|
|
216
|
+
<Field
|
|
217
|
+
label="First name"
|
|
218
|
+
name="first_name"
|
|
219
|
+
type="text"
|
|
220
|
+
value={firstName}
|
|
221
|
+
onChange={(e) => setFirstName(e.target.value)}
|
|
222
|
+
/>
|
|
223
|
+
<Field
|
|
224
|
+
label="Last name"
|
|
225
|
+
name="last_name"
|
|
226
|
+
type="text"
|
|
227
|
+
value={lastName}
|
|
228
|
+
onChange={(e) => setLastName(e.target.value)}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
<OtpInput value={otp} onChange={setOtp} autoFocus />
|
|
234
|
+
{error && <p className="text-sm text-brand-sale text-center">{error}</p>}
|
|
235
|
+
<button type="submit" disabled={pending || otp.length < 6} className="btn-primary w-full disabled:opacity-60">
|
|
236
|
+
{pending ? "Verifying…" : "Verify & continue"}
|
|
237
|
+
</button>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
className="text-sm text-muted hover:text-brand-accent w-full text-center"
|
|
241
|
+
onClick={() => {
|
|
242
|
+
setStep("identifier")
|
|
243
|
+
setOtp("")
|
|
244
|
+
setError(null)
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
Use a different {authMethod === "phone_auth" ? "phone" : "email"}
|
|
248
|
+
</button>
|
|
249
|
+
</form>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<form onSubmit={handleSendOtp} className="space-y-4">
|
|
255
|
+
<div className="flex flex-wrap gap-2">
|
|
256
|
+
<Chip active={authMethod === "email_auth"} onClick={() => setAuthMethod("email_auth")}>
|
|
257
|
+
Email OTP
|
|
258
|
+
</Chip>
|
|
259
|
+
<Chip active={authMethod === "phone_auth"} onClick={() => setAuthMethod("phone_auth")}>
|
|
260
|
+
Phone OTP
|
|
261
|
+
</Chip>
|
|
262
|
+
<Chip active={authMethod === "guest"} onClick={() => setAuthMethod("guest")}>
|
|
263
|
+
Guest orders
|
|
264
|
+
</Chip>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<Field
|
|
268
|
+
label={authMethod === "phone_auth" ? "Phone" : "Email"}
|
|
269
|
+
name="identifier"
|
|
270
|
+
type={authMethod === "phone_auth" ? "tel" : "email"}
|
|
271
|
+
value={identifier}
|
|
272
|
+
onChange={(e) => setIdentifier(e.target.value)}
|
|
273
|
+
autoComplete={authMethod === "phone_auth" ? "tel" : "email"}
|
|
274
|
+
/>
|
|
275
|
+
|
|
276
|
+
<p className="text-xs text-muted">
|
|
277
|
+
{authMethod === "guest"
|
|
278
|
+
? "Track a guest order with a one-time code — no account required."
|
|
279
|
+
: "We will send a one-time code to sign in or create your account."}
|
|
280
|
+
</p>
|
|
281
|
+
|
|
282
|
+
{error && <p className="text-sm text-brand-sale">{error}</p>}
|
|
283
|
+
|
|
284
|
+
<button type="submit" disabled={pending || !identifier.trim()} className="btn-primary w-full disabled:opacity-60">
|
|
285
|
+
{pending ? "Sending…" : "Send code"}
|
|
286
|
+
</button>
|
|
287
|
+
</form>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isNextRedirect(err: unknown) {
|
|
292
|
+
return (
|
|
293
|
+
typeof err === "object" &&
|
|
294
|
+
err !== null &&
|
|
295
|
+
"digest" in err &&
|
|
296
|
+
String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function Chip({
|
|
301
|
+
active,
|
|
302
|
+
onClick,
|
|
303
|
+
children,
|
|
304
|
+
}: {
|
|
305
|
+
active: boolean
|
|
306
|
+
onClick: () => void
|
|
307
|
+
children: React.ReactNode
|
|
308
|
+
}) {
|
|
309
|
+
return (
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={onClick}
|
|
313
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
|
|
314
|
+
active
|
|
315
|
+
? "border-brand-accent text-brand-accent bg-brand-accent/5"
|
|
316
|
+
: "border-cart-border text-muted hover:text-heading"
|
|
317
|
+
}`}
|
|
318
|
+
>
|
|
319
|
+
{children}
|
|
320
|
+
</button>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function Field({
|
|
325
|
+
label,
|
|
326
|
+
name,
|
|
327
|
+
type,
|
|
328
|
+
autoComplete,
|
|
329
|
+
value,
|
|
330
|
+
onChange,
|
|
331
|
+
}: {
|
|
332
|
+
label: string
|
|
333
|
+
name: string
|
|
334
|
+
type: string
|
|
335
|
+
autoComplete?: string
|
|
336
|
+
value?: string
|
|
337
|
+
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
338
|
+
}) {
|
|
339
|
+
return (
|
|
340
|
+
<label className="block">
|
|
341
|
+
<span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
|
|
342
|
+
{label}
|
|
343
|
+
</span>
|
|
344
|
+
<input
|
|
345
|
+
name={name}
|
|
346
|
+
type={type}
|
|
347
|
+
autoComplete={autoComplete}
|
|
348
|
+
value={value}
|
|
349
|
+
onChange={onChange}
|
|
350
|
+
required
|
|
351
|
+
className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
|
|
352
|
+
/>
|
|
353
|
+
</label>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
@@ -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,298 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import {
|
|
5
|
+
registerCustomer,
|
|
6
|
+
sendCustomerOTP,
|
|
7
|
+
verifyCustomerOTP,
|
|
8
|
+
} from "./auth-server"
|
|
9
|
+
import OtpInput from "./otp-input"
|
|
10
|
+
import GoogleAuthSection from "./google-auth-section"
|
|
11
|
+
|
|
12
|
+
type VerificationMethod = "email_verification" | "phone_verification"
|
|
13
|
+
|
|
14
|
+
export default function RegisterForm({
|
|
15
|
+
countryCode,
|
|
16
|
+
onLogin,
|
|
17
|
+
}: {
|
|
18
|
+
countryCode: string
|
|
19
|
+
onLogin: () => void
|
|
20
|
+
}) {
|
|
21
|
+
const [step, setStep] = useState<"form" | "otp" | "success">("form")
|
|
22
|
+
const [verificationMethod, setVerificationMethod] =
|
|
23
|
+
useState<VerificationMethod>("phone_verification")
|
|
24
|
+
const [form, setForm] = useState({
|
|
25
|
+
full_name: "",
|
|
26
|
+
email: "",
|
|
27
|
+
phone: "",
|
|
28
|
+
password: "",
|
|
29
|
+
})
|
|
30
|
+
const [customerId, setCustomerId] = useState<string | null>(null)
|
|
31
|
+
const [otpToken, setOtpToken] = useState<string | null>(null)
|
|
32
|
+
const [otp, setOtp] = useState("")
|
|
33
|
+
const [error, setError] = useState<string | null>(null)
|
|
34
|
+
const [pending, setPending] = useState(false)
|
|
35
|
+
|
|
36
|
+
function updateField(key: keyof typeof form, value: string) {
|
|
37
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleRegister(e: React.FormEvent) {
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
setPending(true)
|
|
43
|
+
setError(null)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (verificationMethod === "phone_verification" && !form.phone.trim()) {
|
|
47
|
+
throw new Error("Phone number is required for mobile verification")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const [first_name, ...rest] = form.full_name.trim().split(/\s+/)
|
|
51
|
+
const last_name = rest.join(" ") || "."
|
|
52
|
+
|
|
53
|
+
const result = await registerCustomer({
|
|
54
|
+
email: form.email.trim(),
|
|
55
|
+
first_name: first_name || "",
|
|
56
|
+
last_name,
|
|
57
|
+
phone: form.phone || undefined,
|
|
58
|
+
password: form.password,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
throw new Error(result.error || "Registration failed")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const id = result.customer?.id
|
|
66
|
+
if (!id) {
|
|
67
|
+
throw new Error("Account was created but customer id is missing")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setCustomerId(id)
|
|
71
|
+
|
|
72
|
+
const otpResult = await sendCustomerOTP(id, verificationMethod)
|
|
73
|
+
if (!otpResult.success) {
|
|
74
|
+
throw new Error(otpResult.error || "Failed to send verification code")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setOtpToken(otpResult.token ?? null)
|
|
78
|
+
setStep("otp")
|
|
79
|
+
} catch (err) {
|
|
80
|
+
setError(err instanceof Error ? err.message : "Registration failed")
|
|
81
|
+
} finally {
|
|
82
|
+
setPending(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleVerifyOtp(e: React.FormEvent) {
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
if (!otp || otp.length < 6) return
|
|
89
|
+
|
|
90
|
+
setPending(true)
|
|
91
|
+
setError(null)
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = await verifyCustomerOTP(otpToken || "", otp)
|
|
95
|
+
if (!result.success) {
|
|
96
|
+
throw new Error(result.error || "Invalid verification code")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setStep("success")
|
|
100
|
+
window.setTimeout(() => onLogin(), 2000)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
setError(err instanceof Error ? err.message : "Verification failed")
|
|
103
|
+
} finally {
|
|
104
|
+
setPending(false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleResendOtp() {
|
|
109
|
+
if (!customerId) return
|
|
110
|
+
setPending(true)
|
|
111
|
+
setError(null)
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const result = await sendCustomerOTP(customerId, verificationMethod)
|
|
115
|
+
if (!result.success) {
|
|
116
|
+
throw new Error(result.error || "Failed to resend code")
|
|
117
|
+
}
|
|
118
|
+
setOtpToken(result.token ?? null)
|
|
119
|
+
setOtp("")
|
|
120
|
+
} catch (err) {
|
|
121
|
+
setError(err instanceof Error ? err.message : "Failed to resend code")
|
|
122
|
+
} finally {
|
|
123
|
+
setPending(false)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (step === "success") {
|
|
128
|
+
return (
|
|
129
|
+
<div className="text-center space-y-4 py-6">
|
|
130
|
+
<div className="w-14 h-14 mx-auto rounded-full bg-green-100 flex items-center justify-center text-2xl">
|
|
131
|
+
✓
|
|
132
|
+
</div>
|
|
133
|
+
<h2 className="section-heading text-lg">Account verified</h2>
|
|
134
|
+
<p className="text-sm text-muted">
|
|
135
|
+
Your account is ready. Redirecting you to sign in…
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (step === "otp") {
|
|
142
|
+
return (
|
|
143
|
+
<form onSubmit={handleVerifyOtp} className="space-y-5">
|
|
144
|
+
<div className="text-center space-y-2">
|
|
145
|
+
<h2 className="section-heading text-lg">Verify your account</h2>
|
|
146
|
+
<p className="text-sm text-muted">
|
|
147
|
+
Enter the 6-digit code sent to your{" "}
|
|
148
|
+
{verificationMethod === "phone_verification" ? "phone" : "email"}.
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<OtpInput value={otp} onChange={setOtp} autoFocus />
|
|
153
|
+
{error && <p className="text-sm text-brand-sale text-center">{error}</p>}
|
|
154
|
+
|
|
155
|
+
<button type="submit" disabled={pending || otp.length < 6} className="btn-primary w-full disabled:opacity-60">
|
|
156
|
+
{pending ? "Verifying…" : "Verify account"}
|
|
157
|
+
</button>
|
|
158
|
+
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
disabled={pending}
|
|
162
|
+
onClick={handleResendOtp}
|
|
163
|
+
className="text-sm text-muted hover:text-brand-accent w-full text-center disabled:opacity-60"
|
|
164
|
+
>
|
|
165
|
+
Resend code
|
|
166
|
+
</button>
|
|
167
|
+
</form>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="space-y-6">
|
|
173
|
+
<GoogleAuthSection countryCode={countryCode} />
|
|
174
|
+
|
|
175
|
+
<div className="relative">
|
|
176
|
+
<div className="absolute inset-0 flex items-center">
|
|
177
|
+
<div className="w-full border-t border-cart-border" />
|
|
178
|
+
</div>
|
|
179
|
+
<div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
|
|
180
|
+
<span className="bg-page-bg px-3 text-muted">Or register with email</span>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<form onSubmit={handleRegister} className="space-y-4">
|
|
185
|
+
<div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
|
|
186
|
+
<VerificationToggle
|
|
187
|
+
active={verificationMethod === "phone_verification"}
|
|
188
|
+
onClick={() => setVerificationMethod("phone_verification")}
|
|
189
|
+
>
|
|
190
|
+
Mobile
|
|
191
|
+
</VerificationToggle>
|
|
192
|
+
<VerificationToggle
|
|
193
|
+
active={verificationMethod === "email_verification"}
|
|
194
|
+
onClick={() => setVerificationMethod("email_verification")}
|
|
195
|
+
>
|
|
196
|
+
Email OTP
|
|
197
|
+
</VerificationToggle>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<Field
|
|
201
|
+
label="Full name"
|
|
202
|
+
value={form.full_name}
|
|
203
|
+
onChange={(e) => updateField("full_name", e.target.value)}
|
|
204
|
+
autoComplete="name"
|
|
205
|
+
/>
|
|
206
|
+
<Field
|
|
207
|
+
label="Email"
|
|
208
|
+
type="email"
|
|
209
|
+
value={form.email}
|
|
210
|
+
onChange={(e) => updateField("email", e.target.value)}
|
|
211
|
+
autoComplete="email"
|
|
212
|
+
/>
|
|
213
|
+
<Field
|
|
214
|
+
label="Phone"
|
|
215
|
+
type="tel"
|
|
216
|
+
value={form.phone}
|
|
217
|
+
onChange={(e) => updateField("phone", e.target.value)}
|
|
218
|
+
autoComplete="tel"
|
|
219
|
+
required={verificationMethod === "phone_verification"}
|
|
220
|
+
/>
|
|
221
|
+
<Field
|
|
222
|
+
label="Password"
|
|
223
|
+
type="password"
|
|
224
|
+
value={form.password}
|
|
225
|
+
onChange={(e) => updateField("password", e.target.value)}
|
|
226
|
+
autoComplete="new-password"
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
{error && <p className="text-sm text-brand-sale">{error}</p>}
|
|
230
|
+
|
|
231
|
+
<button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
|
|
232
|
+
{pending ? "Creating account…" : "Create account"}
|
|
233
|
+
</button>
|
|
234
|
+
</form>
|
|
235
|
+
|
|
236
|
+
<p className="text-sm text-center text-muted">
|
|
237
|
+
Already have an account?{" "}
|
|
238
|
+
<button type="button" onClick={onLogin} className="text-brand-accent font-semibold hover:underline">
|
|
239
|
+
Sign in
|
|
240
|
+
</button>
|
|
241
|
+
</p>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function VerificationToggle({
|
|
247
|
+
active,
|
|
248
|
+
onClick,
|
|
249
|
+
children,
|
|
250
|
+
}: {
|
|
251
|
+
active: boolean
|
|
252
|
+
onClick: () => void
|
|
253
|
+
children: React.ReactNode
|
|
254
|
+
}) {
|
|
255
|
+
return (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={onClick}
|
|
259
|
+
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] rounded-md transition-colors ${
|
|
260
|
+
active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
|
|
261
|
+
}`}
|
|
262
|
+
>
|
|
263
|
+
{children}
|
|
264
|
+
</button>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function Field({
|
|
269
|
+
label,
|
|
270
|
+
type = "text",
|
|
271
|
+
value,
|
|
272
|
+
onChange,
|
|
273
|
+
autoComplete,
|
|
274
|
+
required = true,
|
|
275
|
+
}: {
|
|
276
|
+
label: string
|
|
277
|
+
type?: string
|
|
278
|
+
value: string
|
|
279
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
280
|
+
autoComplete?: string
|
|
281
|
+
required?: boolean
|
|
282
|
+
}) {
|
|
283
|
+
return (
|
|
284
|
+
<label className="block">
|
|
285
|
+
<span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
|
|
286
|
+
{label}
|
|
287
|
+
</span>
|
|
288
|
+
<input
|
|
289
|
+
type={type}
|
|
290
|
+
value={value}
|
|
291
|
+
onChange={onChange}
|
|
292
|
+
autoComplete={autoComplete}
|
|
293
|
+
required={required}
|
|
294
|
+
className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
|
|
295
|
+
/>
|
|
296
|
+
</label>
|
|
297
|
+
)
|
|
298
|
+
}
|