@pradip1995/segment-login-template 0.4.3 → 0.5.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/segment-login-template",
3
- "version": "0.4.3",
3
+ "version": "0.5.3",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -15,15 +15,22 @@
15
15
  "./reset-password": "./src/reset-password-page.tsx",
16
16
  "./otp-verification-modal": "./src/otp-verification-modal.tsx",
17
17
  "./commerce-auth-otp-modal": "./src/commerce-auth-otp-modal.tsx",
18
- "./otp-component": "./src/otp-component.ts"
18
+ "./otp-component": "./src/otp-component.ts",
19
+ "./auth-server": "./src/auth-server.ts",
20
+ "./google-auth-section": "./src/google-auth-section.tsx",
21
+ "./otp-input": "./src/otp-input.tsx"
22
+ },
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit",
25
+ "lint": "tsc --noEmit"
19
26
  },
20
27
  "peerDependencies": {
21
28
  "@pradip1995/commerce-auth": "^4.0.0",
22
29
  "@pradip1995/commerce-core": "^4.0.0",
23
30
  "@pradip1995/plugin-sdk": "^0.2.0",
31
+ "next": ">=15",
24
32
  "react": ">=19",
25
- "react-dom": ">=19",
26
- "next": ">=15"
33
+ "react-dom": ">=19"
27
34
  },
28
35
  "dependencies": {
29
36
  "@pradip1995/commerce-auth": "^4.0.0",
@@ -36,9 +43,5 @@
36
43
  "@types/react": "^19",
37
44
  "react": "19.0.3",
38
45
  "typescript": "^5.7.2"
39
- },
40
- "scripts": {
41
- "typecheck": "tsc --noEmit",
42
- "lint": "tsc --noEmit"
43
46
  }
44
- }
47
+ }
@@ -92,6 +92,13 @@ export default function AccountPaymentMethods({
92
92
  }
93
93
 
94
94
  async function handleDelete(id: string) {
95
+ const target = paymentDetails.find((d) => d.id === id)
96
+ if (target?.is_default && paymentDetails.length === 1) {
97
+ setError(
98
+ "The default payment method cannot be removed. Please add another payment method and set it as default before removing this one."
99
+ )
100
+ return
101
+ }
95
102
  if (!confirm("Remove this payment method?")) return
96
103
  setPending(true)
97
104
  setError(null)
@@ -72,8 +72,20 @@ export const auth = {
72
72
  "px-3 py-1.5 text-xs font-medium border border-cart-border text-muted hover:text-heading transition-colors duration-200",
73
73
  } as const
74
74
 
75
+ export type BrandFeatureCard = {
76
+ imageSrc?: string | null
77
+ title: string
78
+ text: string
79
+ }
80
+
75
81
  export type BrandPanelConfig = {
76
82
  image?: string | null
83
+ logo?: string | null
77
84
  eyebrow?: string | null
78
85
  headline?: string | null
86
+ /** Sahsha alias for headline */
87
+ title?: string | null
88
+ description?: string | null
89
+ featuresLabel?: string | null
90
+ featureCards?: BrandFeatureCard[] | null
79
91
  }
@@ -112,6 +112,8 @@ export async function verifyAuthOtpAndLogin(input: {
112
112
  countryCode: string
113
113
  first_name?: string
114
114
  last_name?: string
115
+ /** When true, return success instead of redirecting (used by login popup). */
116
+ skipRedirect?: boolean
115
117
  }) {
116
118
  const baseUrl = getBaseUrl()
117
119
 
@@ -128,9 +130,17 @@ export async function verifyAuthOtpAndLogin(input: {
128
130
 
129
131
  if (!response.ok) {
130
132
  const err = await response.json().catch(() => ({}))
133
+ const raw =
134
+ (err as { message?: string }).message || "Invalid verification code"
135
+ const lower = raw.toLowerCase()
136
+ const error =
137
+ lower.includes("unable to retrieve the auth provider") &&
138
+ (lower.includes("emailotp") || lower.includes("phoneotp"))
139
+ ? "OTP sign-in is not configured on the backend. Ask the shop admin to enable emailotp/phoneotp auth providers and redeploy."
140
+ : raw.replace(/\s+/g, " ").trim()
131
141
  return {
132
142
  success: false as const,
133
- error: (err as { message?: string }).message || "Invalid verification code",
143
+ error,
134
144
  }
135
145
  }
136
146
 
@@ -151,14 +161,20 @@ export async function verifyAuthOtpAndLogin(input: {
151
161
 
152
162
  await setAuthToken(jwt)
153
163
 
154
- try {
155
- await transferCart(jwt)
156
- } catch {
157
- // non-fatal
158
- }
164
+ // Don't block login on cart merge — middleware recovers guest cart after redirect.
165
+ void transferCart(jwt).catch(() => {})
159
166
 
160
167
  const customerCacheTag = await getCacheTag("customers")
161
- revalidateTag(customerCacheTag)
168
+ if (customerCacheTag) {
169
+ revalidateTag(customerCacheTag)
170
+ }
171
+
172
+ if (input.skipRedirect) {
173
+ return {
174
+ success: true as const,
175
+ isNewUser: data.is_new_user ?? false,
176
+ }
177
+ }
162
178
 
163
179
  const countryCode = input.countryCode || "in"
164
180
  redirect(`/${countryCode}`)
@@ -29,10 +29,12 @@ export default function LoginBrandPanel({
29
29
  variant = "desktop",
30
30
  }: LoginBrandPanelProps) {
31
31
  const image = panel?.image || PLACEHOLDER
32
+ const headline = panel?.headline || panel?.title
33
+ const features = panel?.featureCards?.filter((c) => c.title && c.text) || []
32
34
 
33
35
  if (variant === "mobile") {
34
36
  return (
35
- <div className="relative w-full h-[26vh] min-h-[160px] max-h-[200px] shrink-0 overflow-hidden bg-page-bg lg:hidden">
37
+ <div className="relative w-full h-[26vh] min-h-[160px] max-h-[220px] shrink-0 overflow-hidden bg-page-bg lg:hidden">
36
38
  <Image
37
39
  src={image}
38
40
  alt=""
@@ -42,17 +44,27 @@ export default function LoginBrandPanel({
42
44
  priority
43
45
  unoptimized={image.startsWith("data:")}
44
46
  />
45
- <div className="absolute inset-0 bg-gradient-to-b from-transparent via-page-bg/20 to-page-bg" />
46
- {(panel?.eyebrow || panel?.headline) && (
47
+ <div className="absolute inset-0 bg-gradient-to-b from-black/25 via-transparent to-page-bg" />
48
+ {(panel?.eyebrow || headline) && (
47
49
  <div className="absolute inset-x-0 bottom-0 p-5 z-[1]">
48
- {panel.eyebrow && (
50
+ {panel?.logo && (
51
+ <Image
52
+ src={panel.logo}
53
+ alt=""
54
+ width={140}
55
+ height={40}
56
+ className="h-8 w-auto object-contain mb-2"
57
+ unoptimized
58
+ />
59
+ )}
60
+ {panel?.eyebrow && (
49
61
  <p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-inverse/80 mb-1">
50
62
  {panel.eyebrow}
51
63
  </p>
52
64
  )}
53
- {panel.headline && (
65
+ {headline && (
54
66
  <p className="font-heading text-lg text-inverse font-bold tracking-wide">
55
- {panel.headline}
67
+ {headline}
56
68
  </p>
57
69
  )}
58
70
  </div>
@@ -62,7 +74,7 @@ export default function LoginBrandPanel({
62
74
  }
63
75
 
64
76
  return (
65
- <div className="relative hidden lg:block lg:w-1/2 h-full shrink-0 overflow-hidden bg-page-bg">
77
+ <div className="relative hidden lg:flex lg:w-1/2 h-full shrink-0 overflow-hidden bg-heading flex-col">
66
78
  <Image
67
79
  src={image}
68
80
  alt=""
@@ -73,24 +85,74 @@ export default function LoginBrandPanel({
73
85
  unoptimized={image.startsWith("data:")}
74
86
  />
75
87
  <div
76
- className="absolute inset-y-0 right-0 w-[50%] min-w-[200px] bg-gradient-to-l from-page-bg via-page-bg/80 to-transparent"
88
+ className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/45 to-black/25"
77
89
  aria-hidden
78
90
  />
79
- <div className="absolute inset-0 bg-gradient-to-r from-black/[0.04] via-transparent to-transparent" />
80
- {(panel?.eyebrow || panel?.headline) && (
81
- <div className="absolute inset-x-0 bottom-0 p-10 xl:p-14 z-[1] max-w-md">
82
- {panel.eyebrow && (
83
- <p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-inverse/70 mb-2">
91
+
92
+ <div className="relative z-[1] flex flex-1 flex-col justify-between p-10 xl:p-14 text-inverse">
93
+ <div>
94
+ {panel?.logo ? (
95
+ <Image
96
+ src={panel.logo}
97
+ alt=""
98
+ width={180}
99
+ height={48}
100
+ className="h-12 w-auto object-contain mb-6"
101
+ unoptimized
102
+ />
103
+ ) : panel?.eyebrow ? (
104
+ <p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-inverse/70 mb-3">
84
105
  {panel.eyebrow}
85
106
  </p>
107
+ ) : null}
108
+
109
+ {headline && (
110
+ <h2 className="font-heading text-3xl xl:text-4xl font-bold tracking-wide leading-snug">
111
+ {headline}
112
+ </h2>
86
113
  )}
87
- {panel.headline && (
88
- <p className="font-heading text-2xl xl:text-3xl text-inverse font-bold tracking-wide leading-snug">
89
- {panel.headline}
114
+ {panel?.description && (
115
+ <p className="mt-3 text-sm text-inverse/80 leading-relaxed max-w-md">
116
+ {panel.description}
90
117
  </p>
91
118
  )}
92
119
  </div>
93
- )}
120
+
121
+ {features.length > 0 && (
122
+ <div className="mt-8">
123
+ {panel?.featuresLabel && (
124
+ <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-inverse/60 mb-4">
125
+ {panel.featuresLabel}
126
+ </p>
127
+ )}
128
+ <ul className="space-y-3">
129
+ {features.map((card, index) => (
130
+ <li key={`${card.title}-${index}`} className="flex gap-3 items-start">
131
+ {card.imageSrc ? (
132
+ <Image
133
+ src={card.imageSrc}
134
+ alt=""
135
+ width={28}
136
+ height={28}
137
+ className="mt-0.5 shrink-0"
138
+ unoptimized
139
+ />
140
+ ) : (
141
+ <span
142
+ className="mt-1.5 h-2 w-2 rounded-full bg-brand-accent shrink-0"
143
+ aria-hidden
144
+ />
145
+ )}
146
+ <div>
147
+ <p className="text-sm font-semibold text-inverse">{card.title}</p>
148
+ <p className="text-xs text-inverse/70 mt-0.5">{card.text}</p>
149
+ </div>
150
+ </li>
151
+ ))}
152
+ </ul>
153
+ </div>
154
+ )}
155
+ </div>
94
156
  </div>
95
157
  )
96
158
  }
@@ -257,13 +257,16 @@ function OtpLoginForm({
257
257
  throw new Error("Please enter your first and last name to finish registration")
258
258
  }
259
259
 
260
- await verifyAuthOtpAndLogin({
260
+ const result = await verifyAuthOtpAndLogin({
261
261
  token: otpToken || "",
262
262
  code: otp,
263
263
  countryCode,
264
264
  first_name: isNewUser ? firstName.trim() : undefined,
265
265
  last_name: isNewUser ? lastName.trim() : undefined,
266
266
  })
267
+ if (result && !result.success) {
268
+ throw new Error(result.error || "Verification failed")
269
+ }
267
270
  } catch (err) {
268
271
  if (isNextRedirect(err)) throw err
269
272
  setError(err instanceof Error ? err.message : "Verification failed")
@@ -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,29 @@ 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 (/payment details? with customer(_|\s)?id already exists/i.test(primary)) {
140
+ return "Could not remove this payment method because of a conflict with your default method. Please set another method as default, then try again."
141
+ }
142
+ if (/already exists/i.test(primary) && /customer/i.test(primary)) {
143
+ return "Could not update your default payment method. Please try again, or set another method as default first."
144
+ }
145
+ if (/^failed to delete payment detail\.?$/i.test(primary)) {
146
+ const detail =
147
+ typeof data.error === "string" && data.error.trim() ? data.error.trim() : ""
148
+ if (detail) return detail
149
+ }
150
+ return primary
133
151
  }
134
152
 
135
153
  return `Request failed (${status})`
package/src/segment.tsx CHANGED
@@ -14,6 +14,7 @@ import AccountPaymentMethods from "./account-payment-methods"
14
14
  import AccountGuestOrders from "./account-guest-orders"
15
15
  import LoginBrandPanel from "./login-brand-panel"
16
16
  import GuestOrderModal from "./guest-order-modal"
17
+ import OtpUnifiedForm from "./otp-unified-form"
17
18
  import { resolveOtpComponent, type OtpVerificationComponent } from "./otp-component"
18
19
  import { accountSectionPath } from "./account-utils"
19
20
  import { auth, type BrandPanelConfig } from "./account-theme"
@@ -33,6 +34,11 @@ type AccountProps = {
33
34
  brandPanel?: BrandPanelConfig | null
34
35
  guestTrackLabel?: string
35
36
  otpComponent?: OtpVerificationComponent
37
+ /**
38
+ * `otp` — Sahsha single-screen (Google + Mobile/Email OTP, signup in verify step).
39
+ * `full` — password + OTP tabs with separate register form.
40
+ */
41
+ authMode?: "otp" | "full"
36
42
  }
37
43
 
38
44
  export default function LoginTemplate({
@@ -49,6 +55,7 @@ export default function LoginTemplate({
49
55
  brandPanel,
50
56
  guestTrackLabel = "Track guest order",
51
57
  otpComponent,
58
+ authMode = "otp",
52
59
  }: AccountProps) {
53
60
  const OtpComponent = resolveOtpComponent(otpComponent)
54
61
  const section = accountSectionPath(path)
@@ -124,10 +131,23 @@ export default function LoginTemplate({
124
131
  }
125
132
  }
126
133
 
134
+ const isOtpMode = authMode === "otp"
127
135
  const isSignIn = view === "login"
128
136
  const isRegister = view === "register"
129
- const heading = isSignIn ? title : isRegister ? registerTitle : "Reset password"
130
- const subheading = isSignIn ? subtitle : isRegister ? registerSubtitle : undefined
137
+ const heading = isOtpMode
138
+ ? title
139
+ : isSignIn
140
+ ? title
141
+ : isRegister
142
+ ? registerTitle
143
+ : "Reset password"
144
+ const subheading = isOtpMode
145
+ ? subtitle
146
+ : isSignIn
147
+ ? subtitle
148
+ : isRegister
149
+ ? registerSubtitle
150
+ : undefined
131
151
 
132
152
  return (
133
153
  <>
@@ -140,7 +160,7 @@ export default function LoginTemplate({
140
160
  <div className={auth.formGlow} aria-hidden />
141
161
 
142
162
  <div className={auth.formInner}>
143
- {(isSignIn || isRegister) && (
163
+ {(isOtpMode || isSignIn || isRegister) && view !== "forgot" && (
144
164
  <header className={auth.desktopHeader}>
145
165
  <div className="flex items-center gap-3 mb-3">
146
166
  <span className="h-px flex-1 max-w-[48px] bg-gradient-to-r from-brand-accent to-brand-primary" />
@@ -153,38 +173,57 @@ export default function LoginTemplate({
153
173
  )}
154
174
 
155
175
  <div className={auth.formCard}>
156
- {view !== "forgot" && (
157
- <div className="flex gap-6 mb-8 border-b border-cart-border">
158
- <TabButton active={view === "login"} onClick={() => setView("login")}>
159
- Sign in
160
- </TabButton>
161
- <TabButton active={view === "register"} onClick={() => setView("register")}>
162
- Register
163
- </TabButton>
164
- </div>
165
- )}
176
+ {isOtpMode ? (
177
+ view === "forgot" ? (
178
+ <ForgotPasswordForm onBack={() => setView("login")} />
179
+ ) : (
180
+ <OtpUnifiedForm
181
+ countryCode={countryCode}
182
+ onTrackGuest={() => {
183
+ if (checkingGuest) return
184
+ void handleTrackGuestOrder()
185
+ }}
186
+ />
187
+ )
188
+ ) : (
189
+ <>
190
+ {view !== "forgot" && (
191
+ <div className="flex gap-6 mb-8 border-b border-cart-border">
192
+ <TabButton active={view === "login"} onClick={() => setView("login")}>
193
+ Sign in
194
+ </TabButton>
195
+ <TabButton
196
+ active={view === "register"}
197
+ onClick={() => setView("register")}
198
+ >
199
+ Register
200
+ </TabButton>
201
+ </div>
202
+ )}
166
203
 
167
- {view === "login" && (
168
- <LoginForm
169
- countryCode={countryCode}
170
- OtpComponent={OtpComponent}
171
- onForgot={() => setView("forgot")}
172
- onRegister={() => setView("register")}
173
- onTrackGuest={() => {
174
- if (checkingGuest) return
175
- void handleTrackGuestOrder()
176
- }}
177
- />
178
- )}
179
- {view === "register" && (
180
- <RegisterForm
181
- countryCode={countryCode}
182
- OtpComponent={OtpComponent}
183
- onLogin={() => setView("login")}
184
- />
185
- )}
186
- {view === "forgot" && (
187
- <ForgotPasswordForm onBack={() => setView("login")} />
204
+ {view === "login" && (
205
+ <LoginForm
206
+ countryCode={countryCode}
207
+ OtpComponent={OtpComponent}
208
+ onForgot={() => setView("forgot")}
209
+ onRegister={() => setView("register")}
210
+ onTrackGuest={() => {
211
+ if (checkingGuest) return
212
+ void handleTrackGuestOrder()
213
+ }}
214
+ />
215
+ )}
216
+ {view === "register" && (
217
+ <RegisterForm
218
+ countryCode={countryCode}
219
+ OtpComponent={OtpComponent}
220
+ onLogin={() => setView("login")}
221
+ />
222
+ )}
223
+ {view === "forgot" && (
224
+ <ForgotPasswordForm onBack={() => setView("login")} />
225
+ )}
226
+ </>
188
227
  )}
189
228
  </div>
190
229