@pradip1995/segment-login-template 0.2.5 → 0.4.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,411 @@
1
+ "use client"
2
+
3
+ import { useActionState, useState } from "react"
4
+ import { login } from "@pradip1995/commerce-core/client/actions/customer"
5
+ import { sendAuthOtp, sendOTP, verifyAuthOtpAndLogin, verifyOTP } from "./auth-server"
6
+ import GoogleAuthSection from "./google-auth-section"
7
+ import OtpVerificationModal from "./otp-verification-modal"
8
+ import type { OtpVerificationComponent } from "./otp-component"
9
+
10
+ type LoginMode = "password" | "otp"
11
+
12
+ export default function LoginForm({
13
+ countryCode,
14
+ OtpComponent = OtpVerificationModal,
15
+ onForgot,
16
+ onRegister,
17
+ }: {
18
+ countryCode: string
19
+ OtpComponent?: OtpVerificationComponent
20
+ onForgot: () => void
21
+ onRegister: () => void
22
+ }) {
23
+ const [mode, setMode] = useState<LoginMode>("password")
24
+
25
+ return (
26
+ <div className="space-y-6">
27
+ <GoogleAuthSection countryCode={countryCode} />
28
+
29
+ <div className="relative">
30
+ <div className="absolute inset-0 flex items-center">
31
+ <div className="w-full border-t border-cart-border" />
32
+ </div>
33
+ <div className="relative flex justify-center text-xs uppercase tracking-[var(--letter-spacing-nav)]">
34
+ <span className="bg-page-bg px-3 text-muted">Or continue with</span>
35
+ </div>
36
+ </div>
37
+
38
+ <div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
39
+ <ModeButton active={mode === "password"} onClick={() => setMode("password")}>
40
+ Password
41
+ </ModeButton>
42
+ <ModeButton active={mode === "otp"} onClick={() => setMode("otp")}>
43
+ OTP
44
+ </ModeButton>
45
+ </div>
46
+
47
+ {mode === "password" ? (
48
+ <PasswordLoginForm countryCode={countryCode} onForgot={onForgot} />
49
+ ) : (
50
+ <OtpLoginForm countryCode={countryCode} OtpComponent={OtpComponent} />
51
+ )}
52
+
53
+ <p className="text-sm text-center text-muted">
54
+ New here?{" "}
55
+ <button type="button" onClick={onRegister} className="text-brand-accent font-semibold hover:underline">
56
+ Create an account
57
+ </button>
58
+ </p>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function ModeButton({
64
+ active,
65
+ onClick,
66
+ children,
67
+ }: {
68
+ active: boolean
69
+ onClick: () => void
70
+ children: React.ReactNode
71
+ }) {
72
+ return (
73
+ <button
74
+ type="button"
75
+ onClick={onClick}
76
+ className={`flex-1 py-2 text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] rounded-md transition-colors ${
77
+ active ? "bg-page-bg text-heading shadow-sm" : "text-muted hover:text-heading"
78
+ }`}
79
+ >
80
+ {children}
81
+ </button>
82
+ )
83
+ }
84
+
85
+ function PasswordLoginForm({
86
+ countryCode,
87
+ onForgot,
88
+ }: {
89
+ countryCode: string
90
+ onForgot: () => void
91
+ }) {
92
+ const [state, formAction, pending] = useActionState(login, null)
93
+
94
+ return (
95
+ <form action={formAction} className="space-y-4">
96
+ <input type="hidden" name="country_code" value={countryCode} />
97
+ <Field label="Email or phone" name="email_or_phone" type="text" autoComplete="username" />
98
+ <Field label="Password" name="password" type="password" autoComplete="current-password" />
99
+ {state && typeof state === "string" && state !== "ACCOUNT_DELETION_PENDING" && (
100
+ <p className="text-sm text-brand-sale">
101
+ {state === "Email not verified." || state.includes("not verified")
102
+ ? "Your email is not verified yet. Complete OTP verification during registration, or use OTP sign-in below."
103
+ : state}
104
+ </p>
105
+ )}
106
+ {state === "ACCOUNT_DELETION_PENDING" && (
107
+ <p className="text-sm text-brand-sale">
108
+ This account has a pending deletion request. Check your email to cancel it.
109
+ </p>
110
+ )}
111
+ <button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
112
+ {pending ? "Signing in…" : "Sign in"}
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={onForgot}
117
+ className="text-sm text-muted hover:text-brand-accent w-full text-center"
118
+ >
119
+ Forgot password?
120
+ </button>
121
+ </form>
122
+ )
123
+ }
124
+
125
+ function OtpLoginForm({
126
+ countryCode,
127
+ OtpComponent,
128
+ }: {
129
+ countryCode: string
130
+ OtpComponent: OtpVerificationComponent
131
+ }) {
132
+ const [authMethod, setAuthMethod] = useState<"email_auth" | "phone_auth" | "guest">("email_auth")
133
+ const [identifier, setIdentifier] = useState("")
134
+ const [otpToken, setOtpToken] = useState<string | null>(null)
135
+ const [otp, setOtp] = useState("")
136
+ const [showOtpModal, setShowOtpModal] = useState(false)
137
+ const [error, setError] = useState<string | null>(null)
138
+ const [pending, setPending] = useState(false)
139
+ const [isNewUser, setIsNewUser] = useState(false)
140
+ const [firstName, setFirstName] = useState("")
141
+ const [lastName, setLastName] = useState("")
142
+
143
+ async function handleSendOtp(e: React.FormEvent) {
144
+ e.preventDefault()
145
+ setPending(true)
146
+ setError(null)
147
+
148
+ try {
149
+ if (authMethod === "guest") {
150
+ const res = await sendOTP(identifier)
151
+ if (!res.success && (res as { error?: string }).error) {
152
+ throw new Error((res as { error?: string }).error)
153
+ }
154
+ setOtpToken("guest")
155
+ setOtp("")
156
+ setShowOtpModal(true)
157
+ return
158
+ }
159
+
160
+ const res = await sendAuthOtp(
161
+ authMethod === "email_auth"
162
+ ? { email: identifier.trim(), type: "email_auth" }
163
+ : { phone: identifier.replace(/[^\d+]/g, ""), type: "phone_auth" }
164
+ )
165
+
166
+ if (!res.success) {
167
+ throw new Error(res.error)
168
+ }
169
+
170
+ setOtpToken(res.token ?? null)
171
+ setIsNewUser(res.isNewUser ?? false)
172
+ setOtp("")
173
+ setShowOtpModal(true)
174
+ } catch (err) {
175
+ setError(err instanceof Error ? err.message : "Failed to send code")
176
+ } finally {
177
+ setPending(false)
178
+ }
179
+ }
180
+
181
+ async function handleResendOtp() {
182
+ setPending(true)
183
+ setError(null)
184
+
185
+ try {
186
+ if (authMethod === "guest") {
187
+ const res = await sendOTP(identifier)
188
+ if (!res.success && (res as { error?: string }).error) {
189
+ throw new Error((res as { error?: string }).error)
190
+ }
191
+ setOtp("")
192
+ return
193
+ }
194
+
195
+ const res = await sendAuthOtp(
196
+ authMethod === "email_auth"
197
+ ? { email: identifier.trim(), type: "email_auth" }
198
+ : { phone: identifier.replace(/[^\d+]/g, ""), type: "phone_auth" }
199
+ )
200
+
201
+ if (!res.success) {
202
+ throw new Error(res.error)
203
+ }
204
+
205
+ setOtpToken(res.token ?? null)
206
+ setOtp("")
207
+ } catch (err) {
208
+ setError(err instanceof Error ? err.message : "Failed to resend code")
209
+ } finally {
210
+ setPending(false)
211
+ }
212
+ }
213
+
214
+ async function handleVerifyOtp() {
215
+ if (!otp || otp.length < 6) return
216
+
217
+ setPending(true)
218
+ setError(null)
219
+
220
+ try {
221
+ if (authMethod === "guest") {
222
+ const res = await verifyOTP(identifier, otpToken || "", otp)
223
+ if (!res.success) {
224
+ throw new Error((res as { error?: string }).error || "Invalid code")
225
+ }
226
+ const token = (res as { token?: string }).token
227
+ if (token) {
228
+ document.cookie = `_medusa_guest_token=${token}; path=/; max-age=86400; SameSite=Lax`
229
+ }
230
+ window.location.href = `/${countryCode}/account/guest-orders`
231
+ return
232
+ }
233
+
234
+ if (isNewUser && (!firstName.trim() || !lastName.trim())) {
235
+ throw new Error("Please enter your first and last name to finish registration")
236
+ }
237
+
238
+ await verifyAuthOtpAndLogin({
239
+ token: otpToken || "",
240
+ code: otp,
241
+ countryCode,
242
+ first_name: isNewUser ? firstName.trim() : undefined,
243
+ last_name: isNewUser ? lastName.trim() : undefined,
244
+ })
245
+ } catch (err) {
246
+ if (isNextRedirect(err)) throw err
247
+ setError(err instanceof Error ? err.message : "Verification failed")
248
+ setPending(false)
249
+ }
250
+ }
251
+
252
+ const otpTitle =
253
+ authMethod === "guest"
254
+ ? "Verify guest order access"
255
+ : authMethod === "phone_auth"
256
+ ? "Verify your phone"
257
+ : "Verify your email"
258
+
259
+ return (
260
+ <>
261
+ <form onSubmit={handleSendOtp} className="space-y-4">
262
+ <div className="flex flex-wrap gap-2">
263
+ <Chip active={authMethod === "email_auth"} onClick={() => setAuthMethod("email_auth")}>
264
+ Email OTP
265
+ </Chip>
266
+ <Chip active={authMethod === "phone_auth"} onClick={() => setAuthMethod("phone_auth")}>
267
+ Phone OTP
268
+ </Chip>
269
+ <Chip active={authMethod === "guest"} onClick={() => setAuthMethod("guest")}>
270
+ Guest orders
271
+ </Chip>
272
+ </div>
273
+
274
+ <Field
275
+ label={authMethod === "phone_auth" ? "Phone" : "Email"}
276
+ name="identifier"
277
+ type={authMethod === "phone_auth" ? "tel" : "email"}
278
+ value={identifier}
279
+ onChange={(e) => setIdentifier(e.target.value)}
280
+ autoComplete={authMethod === "phone_auth" ? "tel" : "email"}
281
+ />
282
+
283
+ <p className="text-xs text-muted">
284
+ {authMethod === "guest"
285
+ ? "We will send a one-time code to view your guest orders."
286
+ : "We will send a one-time code to sign in or create your account."}
287
+ </p>
288
+
289
+ {error && !showOtpModal && <p className="text-sm text-brand-sale">{error}</p>}
290
+
291
+ <button
292
+ type="submit"
293
+ disabled={pending || !identifier.trim()}
294
+ className="btn-primary w-full disabled:opacity-60"
295
+ >
296
+ {pending ? "Sending…" : "Send verification code"}
297
+ </button>
298
+ </form>
299
+
300
+ <OtpComponent
301
+ open={showOtpModal}
302
+ preventClose
303
+ title={otpTitle}
304
+ description={`Enter the 6-digit code sent to ${identifier || "your contact"}.`}
305
+ otp={otp}
306
+ onOtpChange={setOtp}
307
+ error={error}
308
+ pending={pending}
309
+ verifyLabel="Verify & sign in"
310
+ onVerify={handleVerifyOtp}
311
+ onResend={handleResendOtp}
312
+ extraContent={
313
+ isNewUser && authMethod !== "guest" ? (
314
+ <div className="grid grid-cols-2 gap-3 mb-6">
315
+ <label className="block">
316
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
317
+ First name
318
+ </span>
319
+ <input
320
+ type="text"
321
+ value={firstName}
322
+ onChange={(e) => setFirstName(e.target.value)}
323
+ required
324
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
325
+ />
326
+ </label>
327
+ <label className="block">
328
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
329
+ Last name
330
+ </span>
331
+ <input
332
+ type="text"
333
+ value={lastName}
334
+ onChange={(e) => setLastName(e.target.value)}
335
+ required
336
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
337
+ />
338
+ </label>
339
+ </div>
340
+ ) : undefined
341
+ }
342
+ />
343
+ </>
344
+ )
345
+ }
346
+
347
+ function isNextRedirect(err: unknown) {
348
+ return (
349
+ typeof err === "object" &&
350
+ err !== null &&
351
+ "digest" in err &&
352
+ String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
353
+ )
354
+ }
355
+
356
+ function Chip({
357
+ active,
358
+ onClick,
359
+ children,
360
+ }: {
361
+ active: boolean
362
+ onClick: () => void
363
+ children: React.ReactNode
364
+ }) {
365
+ return (
366
+ <button
367
+ type="button"
368
+ onClick={onClick}
369
+ className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-colors ${
370
+ active
371
+ ? "border-brand-accent text-brand-accent bg-brand-accent/5"
372
+ : "border-cart-border text-muted hover:text-heading"
373
+ }`}
374
+ >
375
+ {children}
376
+ </button>
377
+ )
378
+ }
379
+
380
+ function Field({
381
+ label,
382
+ name,
383
+ type,
384
+ autoComplete,
385
+ value,
386
+ onChange,
387
+ }: {
388
+ label: string
389
+ name: string
390
+ type: string
391
+ autoComplete?: string
392
+ value?: string
393
+ onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
394
+ }) {
395
+ return (
396
+ <label className="block">
397
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
398
+ {label}
399
+ </span>
400
+ <input
401
+ name={name}
402
+ type={type}
403
+ autoComplete={autoComplete}
404
+ value={value}
405
+ onChange={onChange}
406
+ required
407
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
408
+ />
409
+ </label>
410
+ )
411
+ }
@@ -0,0 +1,14 @@
1
+ import type { ComponentType } from "react"
2
+ import type { OtpVerificationModalProps } from "./otp-verification-modal"
3
+ import DefaultOtpVerificationModal from "./otp-verification-modal"
4
+
5
+ export type OtpVerificationComponent = ComponentType<OtpVerificationModalProps>
6
+
7
+ export function resolveOtpComponent(
8
+ component?: OtpVerificationComponent | string
9
+ ): OtpVerificationComponent {
10
+ if (!component || typeof component === "string") {
11
+ return DefaultOtpVerificationModal
12
+ }
13
+ return component
14
+ }
@@ -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,112 @@
1
+ "use client"
2
+
3
+ import OtpInput from "./otp-input"
4
+
5
+ export type OtpVerificationModalProps = {
6
+ open: boolean
7
+ title: string
8
+ description: string
9
+ otp: string
10
+ onOtpChange: (value: string) => void
11
+ error: string | null
12
+ pending: boolean
13
+ success?: boolean
14
+ successTitle?: string
15
+ successMessage?: string
16
+ verifyLabel?: string
17
+ onVerify: () => void
18
+ onResend: () => void
19
+ onClose?: () => void
20
+ /** When true, backdrop click does not dismiss the modal (registration OTP). */
21
+ preventClose?: boolean
22
+ extraContent?: React.ReactNode
23
+ }
24
+
25
+ export default function OtpVerificationModal({
26
+ open,
27
+ title,
28
+ description,
29
+ otp,
30
+ onOtpChange,
31
+ error,
32
+ pending,
33
+ success = false,
34
+ successTitle = "Verified",
35
+ successMessage = "Redirecting you…",
36
+ verifyLabel = "Verify & continue",
37
+ onVerify,
38
+ onResend,
39
+ onClose,
40
+ preventClose = false,
41
+ extraContent,
42
+ }: OtpVerificationModalProps) {
43
+ if (!open) return null
44
+
45
+ function handleBackdropClick() {
46
+ if (preventClose || pending || success) return
47
+ onClose?.()
48
+ }
49
+
50
+ return (
51
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
52
+ <button
53
+ type="button"
54
+ className="absolute inset-0 bg-black/40"
55
+ aria-label="Close verification dialog"
56
+ onClick={handleBackdropClick}
57
+ />
58
+ <div className="relative w-full max-w-md rounded-2xl bg-page-bg border border-cart-border shadow-xl p-6">
59
+ {success ? (
60
+ <div className="text-center space-y-4 py-4">
61
+ <div className="w-14 h-14 mx-auto rounded-full bg-green-100 flex items-center justify-center text-2xl text-green-600">
62
+
63
+ </div>
64
+ <h2 className="section-heading text-lg">{successTitle}</h2>
65
+ <p className="text-sm text-muted">{successMessage}</p>
66
+ </div>
67
+ ) : (
68
+ <>
69
+ <h2 className="section-heading text-lg text-center mb-2">{title}</h2>
70
+ <p className="text-sm text-muted text-center mb-6">{description}</p>
71
+
72
+ {extraContent}
73
+
74
+ <div className="mb-6">
75
+ <OtpInput value={otp} onChange={onOtpChange} autoFocus />
76
+ </div>
77
+
78
+ {error && (
79
+ <p className="text-sm text-brand-sale text-center mb-4" role="alert">
80
+ {error}
81
+ </p>
82
+ )}
83
+
84
+ <button
85
+ type="button"
86
+ onClick={onVerify}
87
+ disabled={pending || otp.length < 6}
88
+ className="btn-primary w-full disabled:opacity-60 mb-3"
89
+ >
90
+ {pending ? "Verifying…" : verifyLabel}
91
+ </button>
92
+
93
+ <button
94
+ type="button"
95
+ onClick={onResend}
96
+ disabled={pending}
97
+ className="text-sm text-muted hover:text-brand-accent w-full text-center disabled:opacity-60"
98
+ >
99
+ Didn&apos;t receive the code? Resend
100
+ </button>
101
+
102
+ {preventClose && (
103
+ <p className="text-xs text-muted text-center mt-4">
104
+ Verification is required to complete {verifyLabel.includes("sign in") ? "sign in" : "registration"}.
105
+ </p>
106
+ )}
107
+ </>
108
+ )}
109
+ </div>
110
+ </div>
111
+ )
112
+ }