@pradip1995/segment-login-template 0.4.2 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { useActionState, useState } from "react"
4
4
  import { login } from "@pradip1995/commerce-core/client/actions/customer"
5
- import { sendAuthOtp, sendOTP, verifyAuthOtpAndLogin, verifyOTP } from "./auth-server"
5
+ import { sendAuthOtp, verifyAuthOtpAndLogin } from "./auth-server"
6
6
  import GoogleAuthSection from "./google-auth-section"
7
7
  import OtpVerificationModal from "./otp-verification-modal"
8
8
  import type { OtpVerificationComponent } from "./otp-component"
9
+ import { auth } from "./account-theme"
9
10
 
10
11
  type LoginMode = "password" | "otp"
11
12
 
@@ -14,28 +15,31 @@ export default function LoginForm({
14
15
  OtpComponent = OtpVerificationModal,
15
16
  onForgot,
16
17
  onRegister,
18
+ onTrackGuest,
17
19
  }: {
18
20
  countryCode: string
19
21
  OtpComponent?: OtpVerificationComponent
20
22
  onForgot: () => void
21
23
  onRegister: () => void
24
+ onTrackGuest?: () => void
22
25
  }) {
23
26
  const [mode, setMode] = useState<LoginMode>("password")
24
27
 
25
28
  return (
26
- <div className="space-y-6">
29
+ <div className="w-full flex flex-col space-y-6" data-testid="login-page">
30
+ <div className="text-center lg:hidden">
31
+ <Ornament />
32
+ <h2 className={auth.mobileTitle}>Sign in</h2>
33
+ <p className="mt-2 text-[10px] text-muted uppercase tracking-[0.24em]">
34
+ Access your account
35
+ </p>
36
+ </div>
37
+
27
38
  <GoogleAuthSection countryCode={countryCode} />
28
39
 
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>
40
+ <AuthDivider label="Or continue with" />
37
41
 
38
- <div className="flex gap-2 p-1 bg-surface-muted rounded-lg">
42
+ <div className="flex gap-2 p-1 bg-surface-muted/80">
39
43
  <ModeButton active={mode === "password"} onClick={() => setMode("password")}>
40
44
  Password
41
45
  </ModeButton>
@@ -52,10 +56,28 @@ export default function LoginForm({
52
56
 
53
57
  <p className="text-sm text-center text-muted">
54
58
  New here?{" "}
55
- <button type="button" onClick={onRegister} className="text-brand-accent font-semibold hover:underline">
59
+ <button
60
+ type="button"
61
+ onClick={onRegister}
62
+ className="text-heading font-semibold hover:text-brand-accent transition-colors underline underline-offset-2"
63
+ >
56
64
  Create an account
57
65
  </button>
58
66
  </p>
67
+
68
+ {onTrackGuest && (
69
+ <div className="pt-4 border-t border-brand-primary/10">
70
+ <button
71
+ type="button"
72
+ onClick={onTrackGuest}
73
+ className={auth.guestCta}
74
+ data-testid="track-guest-order-button"
75
+ >
76
+ <GuestPackageIcon />
77
+ Track guest order
78
+ </button>
79
+ </div>
80
+ )}
59
81
  </div>
60
82
  )
61
83
  }
@@ -70,13 +92,7 @@ function ModeButton({
70
92
  children: React.ReactNode
71
93
  }) {
72
94
  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
- >
95
+ <button type="button" onClick={onClick} className={active ? auth.modeActive : auth.modeIdle}>
80
96
  {children}
81
97
  </button>
82
98
  )
@@ -90,33 +106,72 @@ function PasswordLoginForm({
90
106
  onForgot: () => void
91
107
  }) {
92
108
  const [state, formAction, pending] = useActionState(login, null)
109
+ const [showPassword, setShowPassword] = useState(false)
93
110
 
94
111
  return (
95
- <form action={formAction} className="space-y-4">
112
+ <form action={formAction} className="space-y-5">
96
113
  <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" />
114
+
115
+ <AuthField
116
+ label="Email or phone"
117
+ name="email_or_phone"
118
+ type="text"
119
+ autoComplete="username"
120
+ placeholder="Enter email or phone"
121
+ />
122
+
123
+ <div>
124
+ <div className="flex items-center justify-between mb-1.5">
125
+ <span className={auth.fieldLabel}>Password</span>
126
+ <button
127
+ type="button"
128
+ onClick={onForgot}
129
+ className={`${auth.fieldLabel} text-brand-accent hover:text-heading transition-colors`}
130
+ >
131
+ Forgot?
132
+ </button>
133
+ </div>
134
+ <div className="relative">
135
+ <input
136
+ name="password"
137
+ type={showPassword ? "text" : "password"}
138
+ autoComplete="current-password"
139
+ required
140
+ placeholder="Enter your password"
141
+ className={`${auth.input} pr-10`}
142
+ data-testid="password-input"
143
+ />
144
+ <button
145
+ type="button"
146
+ onClick={() => setShowPassword((v) => !v)}
147
+ className="absolute inset-y-0 right-0 flex items-center text-muted hover:text-heading transition-colors"
148
+ aria-label={showPassword ? "Hide password" : "Show password"}
149
+ >
150
+ {showPassword ? <EyeOffIcon /> : <EyeIcon />}
151
+ </button>
152
+ </div>
153
+ </div>
154
+
99
155
  {state && typeof state === "string" && state !== "ACCOUNT_DELETION_PENDING" && (
100
- <p className="text-sm text-brand-sale">
156
+ <p className="text-sm text-brand-sale" role="alert">
101
157
  {state === "Email not verified." || state.includes("not verified")
102
158
  ? "Your email is not verified yet. Complete OTP verification during registration, or use OTP sign-in below."
103
159
  : state}
104
160
  </p>
105
161
  )}
106
162
  {state === "ACCOUNT_DELETION_PENDING" && (
107
- <p className="text-sm text-brand-sale">
163
+ <p className="text-sm text-brand-sale" role="alert">
108
164
  This account has a pending deletion request. Check your email to cancel it.
109
165
  </p>
110
166
  )}
111
- <button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
112
- {pending ? "Signing in…" : "Sign in"}
113
- </button>
167
+
114
168
  <button
115
- type="button"
116
- onClick={onForgot}
117
- className="text-sm text-muted hover:text-brand-accent w-full text-center"
169
+ type="submit"
170
+ disabled={pending}
171
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
172
+ data-testid="sign-in-button"
118
173
  >
119
- Forgot password?
174
+ <span className="relative z-[1]">{pending ? "Signing in…" : "Sign in"}</span>
120
175
  </button>
121
176
  </form>
122
177
  )
@@ -129,7 +184,7 @@ function OtpLoginForm({
129
184
  countryCode: string
130
185
  OtpComponent: OtpVerificationComponent
131
186
  }) {
132
- const [authMethod, setAuthMethod] = useState<"email_auth" | "phone_auth" | "guest">("email_auth")
187
+ const [authMethod, setAuthMethod] = useState<"email_auth" | "phone_auth">("email_auth")
133
188
  const [identifier, setIdentifier] = useState("")
134
189
  const [otpToken, setOtpToken] = useState<string | null>(null)
135
190
  const [otp, setOtp] = useState("")
@@ -146,17 +201,6 @@ function OtpLoginForm({
146
201
  setError(null)
147
202
 
148
203
  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
204
  const res = await sendAuthOtp(
161
205
  authMethod === "email_auth"
162
206
  ? { email: identifier.trim(), type: "email_auth" }
@@ -183,15 +227,6 @@ function OtpLoginForm({
183
227
  setError(null)
184
228
 
185
229
  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
230
  const res = await sendAuthOtp(
196
231
  authMethod === "email_auth"
197
232
  ? { email: identifier.trim(), type: "email_auth" }
@@ -218,30 +253,20 @@ function OtpLoginForm({
218
253
  setError(null)
219
254
 
220
255
  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
256
  if (isNewUser && (!firstName.trim() || !lastName.trim())) {
235
257
  throw new Error("Please enter your first and last name to finish registration")
236
258
  }
237
259
 
238
- await verifyAuthOtpAndLogin({
260
+ const result = await verifyAuthOtpAndLogin({
239
261
  token: otpToken || "",
240
262
  code: otp,
241
263
  countryCode,
242
264
  first_name: isNewUser ? firstName.trim() : undefined,
243
265
  last_name: isNewUser ? lastName.trim() : undefined,
244
266
  })
267
+ if (result && !result.success) {
268
+ throw new Error(result.error || "Verification failed")
269
+ }
245
270
  } catch (err) {
246
271
  if (isNextRedirect(err)) throw err
247
272
  setError(err instanceof Error ? err.message : "Verification failed")
@@ -249,13 +274,6 @@ function OtpLoginForm({
249
274
  }
250
275
  }
251
276
 
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
277
  return (
260
278
  <>
261
279
  <form onSubmit={handleSendOtp} className="space-y-4">
@@ -266,41 +284,45 @@ function OtpLoginForm({
266
284
  <Chip active={authMethod === "phone_auth"} onClick={() => setAuthMethod("phone_auth")}>
267
285
  Phone OTP
268
286
  </Chip>
269
- <Chip active={authMethod === "guest"} onClick={() => setAuthMethod("guest")}>
270
- Guest orders
271
- </Chip>
272
287
  </div>
273
288
 
274
- <Field
289
+ <AuthField
275
290
  label={authMethod === "phone_auth" ? "Phone" : "Email"}
276
291
  name="identifier"
277
292
  type={authMethod === "phone_auth" ? "tel" : "email"}
278
293
  value={identifier}
279
294
  onChange={(e) => setIdentifier(e.target.value)}
280
295
  autoComplete={authMethod === "phone_auth" ? "tel" : "email"}
296
+ placeholder={
297
+ authMethod === "phone_auth" ? "Enter your phone" : "Enter your email"
298
+ }
281
299
  />
282
300
 
283
301
  <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."}
302
+ We will send a one-time code to sign in or create your account.
287
303
  </p>
288
304
 
289
- {error && !showOtpModal && <p className="text-sm text-brand-sale">{error}</p>}
305
+ {error && !showOtpModal && (
306
+ <p className="text-sm text-brand-sale" role="alert">
307
+ {error}
308
+ </p>
309
+ )}
290
310
 
291
311
  <button
292
312
  type="submit"
293
313
  disabled={pending || !identifier.trim()}
294
- className="btn-primary w-full disabled:opacity-60"
314
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
295
315
  >
296
- {pending ? "Sending…" : "Send verification code"}
316
+ <span className="relative z-[1]">
317
+ {pending ? "Sending…" : "Send verification code"}
318
+ </span>
297
319
  </button>
298
320
  </form>
299
321
 
300
322
  <OtpComponent
301
323
  open={showOtpModal}
302
324
  preventClose
303
- title={otpTitle}
325
+ title={authMethod === "phone_auth" ? "Verify your phone" : "Verify your email"}
304
326
  description={`Enter the 6-digit code sent to ${identifier || "your contact"}.`}
305
327
  otp={otp}
306
328
  onOtpChange={setOtp}
@@ -310,32 +332,22 @@ function OtpLoginForm({
310
332
  onVerify={handleVerifyOtp}
311
333
  onResend={handleResendOtp}
312
334
  extraContent={
313
- isNewUser && authMethod !== "guest" ? (
335
+ isNewUser ? (
314
336
  <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>
337
+ <AuthField
338
+ label="First name"
339
+ name="first_name"
340
+ value={firstName}
341
+ onChange={(e) => setFirstName(e.target.value)}
342
+ required
343
+ />
344
+ <AuthField
345
+ label="Last name"
346
+ name="last_name"
347
+ value={lastName}
348
+ onChange={(e) => setLastName(e.target.value)}
349
+ required
350
+ />
339
351
  </div>
340
352
  ) : undefined
341
353
  }
@@ -344,68 +356,120 @@ function OtpLoginForm({
344
356
  )
345
357
  }
346
358
 
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
- }) {
359
+ export function AuthDivider({ label }: { label: string }) {
365
360
  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>
361
+ <div className="relative">
362
+ <div className="absolute inset-0 flex items-center">
363
+ <div className="w-full border-t border-brand-primary/10" />
364
+ </div>
365
+ <div className="relative flex justify-center items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.28em]">
366
+ <span className="px-2 bg-surface text-brand-accent">◆</span>
367
+ <span className="px-1 bg-surface text-muted">{label}</span>
368
+ <span className="px-2 bg-surface text-brand-accent">◆</span>
369
+ </div>
370
+ </div>
377
371
  )
378
372
  }
379
373
 
380
- function Field({
374
+ export function AuthField({
381
375
  label,
382
376
  name,
383
- type,
377
+ type = "text",
384
378
  autoComplete,
385
379
  value,
386
380
  onChange,
381
+ placeholder,
382
+ required = true,
387
383
  }: {
388
384
  label: string
389
- name: string
390
- type: string
385
+ name?: string
386
+ type?: string
391
387
  autoComplete?: string
392
388
  value?: string
393
389
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
390
+ placeholder?: string
391
+ required?: boolean
394
392
  }) {
395
393
  return (
396
394
  <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>
395
+ <span className={`${auth.fieldLabel} mb-1.5 block`}>{label}</span>
400
396
  <input
401
397
  name={name}
402
398
  type={type}
403
399
  autoComplete={autoComplete}
404
400
  value={value}
405
401
  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"
402
+ placeholder={placeholder}
403
+ required={required}
404
+ className={auth.input}
408
405
  />
409
406
  </label>
410
407
  )
411
408
  }
409
+
410
+ export function Ornament() {
411
+ return (
412
+ <div className="flex items-center justify-center gap-2 mb-3" aria-hidden>
413
+ <span className="h-px w-6 bg-brand-accent/70" />
414
+ <span className="text-brand-accent text-[7px] leading-none">◆</span>
415
+ <span className="h-px w-6 bg-brand-accent/70" />
416
+ </div>
417
+ )
418
+ }
419
+
420
+ function Chip({
421
+ active,
422
+ onClick,
423
+ children,
424
+ }: {
425
+ active: boolean
426
+ onClick: () => void
427
+ children: React.ReactNode
428
+ }) {
429
+ return (
430
+ <button
431
+ type="button"
432
+ onClick={onClick}
433
+ className={active ? auth.chipActive : auth.chipIdle}
434
+ >
435
+ {children}
436
+ </button>
437
+ )
438
+ }
439
+
440
+ function isNextRedirect(err: unknown) {
441
+ return (
442
+ typeof err === "object" &&
443
+ err !== null &&
444
+ "digest" in err &&
445
+ String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
446
+ )
447
+ }
448
+
449
+ function GuestPackageIcon() {
450
+ return (
451
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden>
452
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" strokeLinecap="round" strokeLinejoin="round" />
453
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" strokeLinecap="round" strokeLinejoin="round" />
454
+ <line x1="12" y1="22.08" x2="12" y2="12" strokeLinecap="round" />
455
+ </svg>
456
+ )
457
+ }
458
+
459
+ function EyeIcon() {
460
+ return (
461
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
462
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
463
+ <circle cx="12" cy="12" r="3" />
464
+ </svg>
465
+ )
466
+ }
467
+
468
+ function EyeOffIcon() {
469
+ return (
470
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
471
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
472
+ <line x1="1" y1="1" x2="23" y2="23" />
473
+ </svg>
474
+ )
475
+ }