@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.
@@ -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
+ }