@pradip1995/segment-login-template 0.4.3 → 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.
- package/package.json +10 -7
- package/src/account-payment-methods.tsx +7 -0
- package/src/account-theme.ts +12 -0
- package/src/auth-server.ts +23 -7
- package/src/login-brand-panel.tsx +79 -17
- package/src/login-form.tsx +4 -1
- package/src/otp-unified-form.tsx +340 -0
- package/src/payment-methods-actions.ts +16 -4
- package/src/segment.tsx +73 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pradip1995/segment-login-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -15,7 +15,14 @@
|
|
|
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",
|
|
@@ -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)
|
package/src/account-theme.ts
CHANGED
|
@@ -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
|
}
|
package/src/auth-server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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-[
|
|
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-
|
|
46
|
-
{(panel?.eyebrow ||
|
|
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
|
|
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
|
-
{
|
|
65
|
+
{headline && (
|
|
54
66
|
<p className="font-heading text-lg text-inverse font-bold tracking-wide">
|
|
55
|
-
{
|
|
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:
|
|
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-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
<div
|
|
82
|
-
{panel
|
|
83
|
-
<
|
|
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
|
|
88
|
-
<p className="
|
|
89
|
-
{panel.
|
|
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
|
}
|
package/src/login-form.tsx
CHANGED
|
@@ -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,23 @@ function parseBackendError(body: unknown, status: number): string {
|
|
|
124
125
|
if (details.length) return details.join("; ")
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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})`
|
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 =
|
|
130
|
-
|
|
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
|
-
{
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|