@pradip1995/segment-login-template 0.4.2 → 0.5.2

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.
@@ -0,0 +1,340 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { SplitPhoneInput } from "@pradip1995/commerce-core/components/split-phone-input"
5
+ import {
6
+ formatPhoneDisplay,
7
+ isValidPhoneParts,
8
+ parsePhoneParts,
9
+ toE164Phone,
10
+ } from "@pradip1995/commerce-core/util/phone"
11
+ import { sendAuthOtp, verifyAuthOtpAndLogin } from "./auth-server"
12
+ import GoogleAuthSection from "./google-auth-section"
13
+ import OtpInput from "./otp-input"
14
+ import { auth } from "./account-theme"
15
+ import { AuthDivider, AuthField, Ornament } from "./login-form"
16
+
17
+ type AuthMethod = "email_auth" | "phone_auth"
18
+ type OtpStep = "identifier" | "verify"
19
+
20
+ const RESEND_COOLDOWN_SEC = 30
21
+
22
+ /**
23
+ * Sahsha-style single-screen OTP auth: Google + Mobile/Email OTP.
24
+ * New users finish signup in the same verify step (first name).
25
+ */
26
+ export default function OtpUnifiedForm({
27
+ countryCode,
28
+ onTrackGuest,
29
+ }: {
30
+ countryCode: string
31
+ onTrackGuest?: () => void
32
+ }) {
33
+ const [authMethod, setAuthMethod] = useState<AuthMethod>("phone_auth")
34
+ const [identifier, setIdentifier] = useState("")
35
+ const [otpToken, setOtpToken] = useState<string | null>(null)
36
+ const [otp, setOtp] = useState("")
37
+ const [step, setStep] = useState<OtpStep>("identifier")
38
+ const [error, setError] = useState<string | null>(null)
39
+ const [sending, setSending] = useState(false)
40
+ const [verifying, setVerifying] = useState(false)
41
+ const [isNewUser, setIsNewUser] = useState(false)
42
+ const [firstName, setFirstName] = useState("")
43
+ const [resendIn, setResendIn] = useState(0)
44
+
45
+ useEffect(() => {
46
+ if (resendIn <= 0) return
47
+ const timer = window.setTimeout(() => setResendIn((s) => s - 1), 1000)
48
+ return () => window.clearTimeout(timer)
49
+ }, [resendIn])
50
+
51
+ const phoneParts = parsePhoneParts(identifier)
52
+ const phoneReady =
53
+ authMethod === "phone_auth" &&
54
+ isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
55
+ const identifierReady =
56
+ authMethod === "email_auth" ? Boolean(identifier.trim()) : phoneReady
57
+
58
+ async function handleSendOtp(options?: { resend?: boolean }) {
59
+ if (!identifierReady || sending) return
60
+ setSending(true)
61
+ setError(null)
62
+
63
+ // Advance immediately so the user is not stuck on "Sending…" during SMTP/SMS.
64
+ if (!options?.resend) {
65
+ setOtp("")
66
+ setFirstName("")
67
+ setOtpToken(null)
68
+ setStep("verify")
69
+ }
70
+
71
+ try {
72
+ const res = await sendAuthOtp(
73
+ authMethod === "email_auth"
74
+ ? { email: identifier.trim(), type: "email_auth" }
75
+ : { phone: toE164Phone(identifier), type: "phone_auth" }
76
+ )
77
+ if (!res.success) throw new Error(res.error)
78
+ setOtpToken(res.token ?? null)
79
+ setIsNewUser(res.isNewUser ?? false)
80
+ setOtp("")
81
+ setResendIn(RESEND_COOLDOWN_SEC)
82
+ } catch (err) {
83
+ const message = err instanceof Error ? err.message : "Failed to send code"
84
+ setError(message)
85
+ if (!options?.resend) {
86
+ setStep("identifier")
87
+ }
88
+ } finally {
89
+ setSending(false)
90
+ }
91
+ }
92
+
93
+ async function handleVerify() {
94
+ if (!otp || otp.length < 6 || !otpToken || verifying || sending) return
95
+ setVerifying(true)
96
+ setError(null)
97
+
98
+ try {
99
+ if (isNewUser && !firstName.trim()) {
100
+ throw new Error("Please enter your first name to finish registration")
101
+ }
102
+
103
+ const result = await verifyAuthOtpAndLogin({
104
+ token: otpToken,
105
+ code: otp,
106
+ countryCode,
107
+ first_name: isNewUser ? firstName.trim() : undefined,
108
+ last_name: isNewUser ? "." : undefined,
109
+ })
110
+ if (result && !result.success) {
111
+ throw new Error(result.error || "Verification failed")
112
+ }
113
+ } catch (err) {
114
+ if (isNextRedirect(err)) throw err
115
+ setError(err instanceof Error ? err.message : "Verification failed")
116
+ setVerifying(false)
117
+ }
118
+ }
119
+
120
+ function resetToIdentifier() {
121
+ setStep("identifier")
122
+ setOtp("")
123
+ setOtpToken(null)
124
+ setError(null)
125
+ setIsNewUser(false)
126
+ setFirstName("")
127
+ setSending(false)
128
+ setVerifying(false)
129
+ setResendIn(0)
130
+ }
131
+
132
+ const identifierDisplay =
133
+ authMethod === "phone_auth" ? formatPhoneDisplay(identifier) || identifier : identifier
134
+ const channelLabel = authMethod === "email_auth" ? "email" : "number"
135
+ const verifyHint = sending
136
+ ? `Sending code to ${identifierDisplay}…`
137
+ : `Enter the 6-digit code sent to ${identifierDisplay}`
138
+
139
+ return (
140
+ <div className="w-full flex flex-col space-y-5" data-testid="login-page">
141
+ <div className="text-center lg:hidden">
142
+ <Ornament />
143
+ <h2 className={auth.mobileTitle}>Sign in</h2>
144
+ <p className="mt-2 text-[10px] text-muted uppercase tracking-[0.24em]">
145
+ Select your login mode to continue
146
+ </p>
147
+ </div>
148
+
149
+ {step === "identifier" ? (
150
+ <>
151
+ <GoogleAuthSection countryCode={countryCode} />
152
+ <AuthDivider label="Or continue with email / mobile" />
153
+
154
+ <div className="flex gap-2 p-1 bg-surface-muted/80">
155
+ <ModeButton
156
+ active={authMethod === "phone_auth"}
157
+ onClick={() => {
158
+ setAuthMethod("phone_auth")
159
+ setIdentifier("")
160
+ setError(null)
161
+ }}
162
+ >
163
+ Mobile
164
+ </ModeButton>
165
+ <ModeButton
166
+ active={authMethod === "email_auth"}
167
+ onClick={() => {
168
+ setAuthMethod("email_auth")
169
+ setIdentifier("")
170
+ setError(null)
171
+ }}
172
+ >
173
+ Email
174
+ </ModeButton>
175
+ </div>
176
+
177
+ {authMethod === "phone_auth" ? (
178
+ <SplitPhoneInput
179
+ value={identifier}
180
+ onChange={setIdentifier}
181
+ label="Mobile number"
182
+ numberInputId="account-login-phone"
183
+ data-testid="account-login-phone"
184
+ />
185
+ ) : (
186
+ <AuthField
187
+ label="Email"
188
+ name="identifier"
189
+ type="email"
190
+ value={identifier}
191
+ onChange={(e) => setIdentifier(e.target.value)}
192
+ autoComplete="email"
193
+ placeholder="Enter your email"
194
+ />
195
+ )}
196
+
197
+ {error && (
198
+ <p className="text-sm text-brand-sale" role="alert">
199
+ {error}
200
+ </p>
201
+ )}
202
+
203
+ <button
204
+ type="button"
205
+ disabled={sending || !identifierReady}
206
+ onClick={() => void handleSendOtp()}
207
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
208
+ >
209
+ <span className="relative z-[1]">{sending ? "Continue…" : "Continue"}</span>
210
+ </button>
211
+ </>
212
+ ) : (
213
+ <div className="space-y-5">
214
+ <div className="text-center">
215
+ <h3 className="font-heading text-lg font-bold text-heading uppercase tracking-[0.08em]">
216
+ Verify OTP
217
+ </h3>
218
+ <p className="mt-2 text-sm text-muted" aria-live="polite">
219
+ {sending ? (
220
+ verifyHint
221
+ ) : (
222
+ <>
223
+ Enter the 6-digit code sent to{" "}
224
+ <span className="text-heading font-medium">{identifierDisplay}</span>
225
+ </>
226
+ )}
227
+ </p>
228
+ </div>
229
+
230
+ {isNewUser && (
231
+ <AuthField
232
+ label="First name"
233
+ name="first_name"
234
+ value={firstName}
235
+ onChange={(e) => setFirstName(e.target.value)}
236
+ placeholder="Your first name"
237
+ required
238
+ />
239
+ )}
240
+
241
+ <OtpInput
242
+ value={otp}
243
+ onChange={setOtp}
244
+ autoFocus={!isNewUser && !sending && Boolean(otpToken)}
245
+ />
246
+
247
+ {error && (
248
+ <p className="text-sm text-brand-sale text-center" role="alert">
249
+ {error}
250
+ </p>
251
+ )}
252
+
253
+ <button
254
+ type="button"
255
+ disabled={
256
+ sending ||
257
+ verifying ||
258
+ !otpToken ||
259
+ otp.length < 6 ||
260
+ (isNewUser && !firstName.trim())
261
+ }
262
+ onClick={() => void handleVerify()}
263
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
264
+ >
265
+ <span className="relative z-[1]">
266
+ {sending
267
+ ? "Waiting for code…"
268
+ : verifying
269
+ ? "Verifying…"
270
+ : isNewUser
271
+ ? "Verify & create account"
272
+ : "Verify & sign in"}
273
+ </span>
274
+ </button>
275
+
276
+ <div className="flex items-center justify-between gap-3 text-xs">
277
+ <button
278
+ type="button"
279
+ onClick={resetToIdentifier}
280
+ disabled={verifying}
281
+ className="text-muted hover:text-brand-accent transition-colors disabled:opacity-50"
282
+ >
283
+ Change {channelLabel}
284
+ </button>
285
+ <button
286
+ type="button"
287
+ disabled={sending || verifying || resendIn > 0}
288
+ onClick={() => void handleSendOtp({ resend: true })}
289
+ className="text-muted hover:text-brand-accent transition-colors disabled:opacity-50"
290
+ >
291
+ {sending
292
+ ? "Resending…"
293
+ : resendIn > 0
294
+ ? `Resend in ${resendIn}s`
295
+ : "Resend OTP"}
296
+ </button>
297
+ </div>
298
+ </div>
299
+ )}
300
+
301
+ {onTrackGuest && step === "identifier" && (
302
+ <div className="pt-4 border-t border-brand-primary/10">
303
+ <button
304
+ type="button"
305
+ onClick={onTrackGuest}
306
+ className={auth.guestCta}
307
+ data-testid="track-guest-order-button"
308
+ >
309
+ Track guest order
310
+ </button>
311
+ </div>
312
+ )}
313
+ </div>
314
+ )
315
+ }
316
+
317
+ function ModeButton({
318
+ active,
319
+ onClick,
320
+ children,
321
+ }: {
322
+ active: boolean
323
+ onClick: () => void
324
+ children: React.ReactNode
325
+ }) {
326
+ return (
327
+ <button type="button" onClick={onClick} className={active ? auth.modeActive : auth.modeIdle}>
328
+ {children}
329
+ </button>
330
+ )
331
+ }
332
+
333
+ function isNextRedirect(err: unknown) {
334
+ return (
335
+ typeof err === "object" &&
336
+ err !== null &&
337
+ "digest" in err &&
338
+ String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
339
+ )
340
+ }
@@ -109,6 +109,7 @@ function parseBackendError(body: unknown, status: number): string {
109
109
 
110
110
  const data = body as {
111
111
  message?: unknown
112
+ error?: unknown
112
113
  errors?: Array<{ message?: string; path?: string[]; field?: string }>
113
114
  }
114
115
 
@@ -124,12 +125,23 @@ function parseBackendError(body: unknown, status: number): string {
124
125
  if (details.length) return details.join("; ")
125
126
  }
126
127
 
127
- if (typeof data.message === "string" && data.message.trim()) {
128
- const msg = data.message.trim()
129
- if (/^invalid request( data)?\.?$/i.test(msg)) {
128
+ const primary =
129
+ typeof data.message === "string" && data.message.trim()
130
+ ? data.message.trim()
131
+ : typeof data.error === "string" && data.error.trim()
132
+ ? data.error.trim()
133
+ : ""
134
+
135
+ if (primary) {
136
+ if (/^invalid request( data)?\.?$/i.test(primary)) {
130
137
  return "Invalid payment details. Check required fields and try again."
131
138
  }
132
- return msg
139
+ if (/^failed to delete payment detail\.?$/i.test(primary)) {
140
+ const detail =
141
+ typeof data.error === "string" && data.error.trim() ? data.error.trim() : ""
142
+ if (detail) return detail
143
+ }
144
+ return primary
133
145
  }
134
146
 
135
147
  return `Request failed (${status})`
@@ -9,6 +9,8 @@ import {
9
9
  import GoogleAuthSection from "./google-auth-section"
10
10
  import OtpVerificationModal from "./otp-verification-modal"
11
11
  import type { OtpVerificationComponent } from "./otp-component"
12
+ import { auth } from "./account-theme"
13
+ import { AuthDivider, AuthField, Ornament } from "./login-form"
12
14
 
13
15
  type VerificationMethod = "email_verification" | "phone_verification"
14
16
  type RegistrationStep = "form" | "otp" | "success"
@@ -37,6 +39,7 @@ export default function RegisterForm({
37
39
  const [error, setError] = useState<string | null>(null)
38
40
  const [pending, setPending] = useState(false)
39
41
  const [showOtpModal, setShowOtpModal] = useState(false)
42
+ const [showPassword, setShowPassword] = useState(false)
40
43
 
41
44
  function updateField(key: keyof typeof form, value: string) {
42
45
  setForm((prev) => ({ ...prev, [key]: value }))
@@ -143,21 +146,22 @@ export default function RegisterForm({
143
146
  verificationMethod === "email_verification" ? form.email : form.phone
144
147
 
145
148
  return (
146
- <div className="space-y-6">
149
+ <div className="space-y-6" data-testid="register-page">
150
+ <div className="text-center lg:hidden">
151
+ <Ornament />
152
+ <h2 className={auth.mobileTitle}>Create account</h2>
153
+ <p className="mt-2 text-[10px] text-muted uppercase tracking-[0.24em]">
154
+ Join our collection
155
+ </p>
156
+ </div>
157
+
147
158
  <GoogleAuthSection countryCode={countryCode} />
148
159
 
149
- <div className="relative">
150
- <div className="absolute inset-0 flex items-center">
151
- <div className="w-full border-t border-cart-border" />
152
- </div>
153
- <div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
154
- <span className="bg-page-bg px-3 text-muted">Or register with email</span>
155
- </div>
156
- </div>
160
+ <AuthDivider label="Or register with email" />
157
161
 
158
162
  {step === "form" && (
159
163
  <>
160
- <div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
164
+ <div className="flex gap-2 p-1 bg-surface-muted/80">
161
165
  <VerificationToggle
162
166
  active={verificationMethod === "email_verification"}
163
167
  onClick={() => setVerificationMethod("email_verification")}
@@ -179,39 +183,67 @@ export default function RegisterForm({
179
183
  </p>
180
184
 
181
185
  <form onSubmit={handleRegister} className="space-y-4">
182
- <Field
186
+ <AuthField
183
187
  label="Full name"
184
188
  value={form.full_name}
185
189
  onChange={(e) => updateField("full_name", e.target.value)}
186
190
  autoComplete="name"
191
+ placeholder="Your full name"
187
192
  />
188
- <Field
193
+ <AuthField
189
194
  label="Email"
190
195
  type="email"
191
196
  value={form.email}
192
197
  onChange={(e) => updateField("email", e.target.value)}
193
198
  autoComplete="email"
199
+ placeholder="you@example.com"
194
200
  />
195
- <Field
201
+ <AuthField
196
202
  label="Phone"
197
203
  type="tel"
198
204
  value={form.phone}
199
205
  onChange={(e) => updateField("phone", e.target.value)}
200
206
  autoComplete="tel"
201
207
  required={verificationMethod === "phone_verification"}
208
+ placeholder="Phone number"
202
209
  />
203
- <Field
204
- label="Password"
205
- type="password"
206
- value={form.password}
207
- onChange={(e) => updateField("password", e.target.value)}
208
- autoComplete="new-password"
209
- />
210
-
211
- {error && <p className="text-sm text-brand-sale">{error}</p>}
212
-
213
- <button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
214
- {pending ? "Creating account…" : "Create account"}
210
+ <div>
211
+ <span className={`${auth.fieldLabel} mb-1.5 block`}>Password</span>
212
+ <div className="relative">
213
+ <input
214
+ type={showPassword ? "text" : "password"}
215
+ value={form.password}
216
+ onChange={(e) => updateField("password", e.target.value)}
217
+ autoComplete="new-password"
218
+ required
219
+ placeholder="Create a password"
220
+ className={`${auth.input} pr-10`}
221
+ />
222
+ <button
223
+ type="button"
224
+ onClick={() => setShowPassword((v) => !v)}
225
+ className="absolute inset-y-0 right-0 flex items-center text-muted hover:text-heading transition-colors"
226
+ aria-label={showPassword ? "Hide password" : "Show password"}
227
+ >
228
+ {showPassword ? <EyeOffIcon /> : <EyeIcon />}
229
+ </button>
230
+ </div>
231
+ </div>
232
+
233
+ {error && (
234
+ <p className="text-sm text-brand-sale" role="alert">
235
+ {error}
236
+ </p>
237
+ )}
238
+
239
+ <button
240
+ type="submit"
241
+ disabled={pending}
242
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
243
+ >
244
+ <span className="relative z-[1]">
245
+ {pending ? "Creating account…" : "Create account"}
246
+ </span>
215
247
  </button>
216
248
  </form>
217
249
  </>
@@ -225,7 +257,11 @@ export default function RegisterForm({
225
257
 
226
258
  <p className="text-sm text-center text-muted">
227
259
  Already have an account?{" "}
228
- <button type="button" onClick={onLogin} className="text-brand-accent font-semibold hover:underline">
260
+ <button
261
+ type="button"
262
+ onClick={onLogin}
263
+ className="text-heading font-semibold hover:text-brand-accent transition-colors underline underline-offset-2"
264
+ >
229
265
  Sign in
230
266
  </button>
231
267
  </p>
@@ -282,43 +318,27 @@ function VerificationToggle({
282
318
  <button
283
319
  type="button"
284
320
  onClick={onClick}
285
- className={`flex-1 py-2 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] rounded-md transition-colors ${
286
- active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
287
- }`}
321
+ className={active ? auth.modeActive : auth.modeIdle}
288
322
  >
289
323
  {children}
290
324
  </button>
291
325
  )
292
326
  }
293
327
 
294
- function Field({
295
- label,
296
- type = "text",
297
- value,
298
- onChange,
299
- autoComplete,
300
- required = true,
301
- }: {
302
- label: string
303
- type?: string
304
- value: string
305
- onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
306
- autoComplete?: string
307
- required?: boolean
308
- }) {
328
+ function EyeIcon() {
309
329
  return (
310
- <label className="block">
311
- <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
312
- {label}
313
- </span>
314
- <input
315
- type={type}
316
- value={value}
317
- onChange={onChange}
318
- autoComplete={autoComplete}
319
- required={required}
320
- className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
321
- />
322
- </label>
330
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
331
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
332
+ <circle cx="12" cy="12" r="3" />
333
+ </svg>
334
+ )
335
+ }
336
+
337
+ function EyeOffIcon() {
338
+ return (
339
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
340
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
341
+ <line x1="1" y1="1" x2="23" y2="23" />
342
+ </svg>
323
343
  )
324
344
  }