@pradip1995/segment-login-template 0.4.1 → 0.4.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.
@@ -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,19 +253,6 @@ 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
  }
@@ -249,13 +271,6 @@ function OtpLoginForm({
249
271
  }
250
272
  }
251
273
 
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
274
  return (
260
275
  <>
261
276
  <form onSubmit={handleSendOtp} className="space-y-4">
@@ -266,41 +281,45 @@ function OtpLoginForm({
266
281
  <Chip active={authMethod === "phone_auth"} onClick={() => setAuthMethod("phone_auth")}>
267
282
  Phone OTP
268
283
  </Chip>
269
- <Chip active={authMethod === "guest"} onClick={() => setAuthMethod("guest")}>
270
- Guest orders
271
- </Chip>
272
284
  </div>
273
285
 
274
- <Field
286
+ <AuthField
275
287
  label={authMethod === "phone_auth" ? "Phone" : "Email"}
276
288
  name="identifier"
277
289
  type={authMethod === "phone_auth" ? "tel" : "email"}
278
290
  value={identifier}
279
291
  onChange={(e) => setIdentifier(e.target.value)}
280
292
  autoComplete={authMethod === "phone_auth" ? "tel" : "email"}
293
+ placeholder={
294
+ authMethod === "phone_auth" ? "Enter your phone" : "Enter your email"
295
+ }
281
296
  />
282
297
 
283
298
  <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."}
299
+ We will send a one-time code to sign in or create your account.
287
300
  </p>
288
301
 
289
- {error && !showOtpModal && <p className="text-sm text-brand-sale">{error}</p>}
302
+ {error && !showOtpModal && (
303
+ <p className="text-sm text-brand-sale" role="alert">
304
+ {error}
305
+ </p>
306
+ )}
290
307
 
291
308
  <button
292
309
  type="submit"
293
310
  disabled={pending || !identifier.trim()}
294
- className="btn-primary w-full disabled:opacity-60"
311
+ className={`${auth.btnPrimary} ${auth.btnShine}`}
295
312
  >
296
- {pending ? "Sending…" : "Send verification code"}
313
+ <span className="relative z-[1]">
314
+ {pending ? "Sending…" : "Send verification code"}
315
+ </span>
297
316
  </button>
298
317
  </form>
299
318
 
300
319
  <OtpComponent
301
320
  open={showOtpModal}
302
321
  preventClose
303
- title={otpTitle}
322
+ title={authMethod === "phone_auth" ? "Verify your phone" : "Verify your email"}
304
323
  description={`Enter the 6-digit code sent to ${identifier || "your contact"}.`}
305
324
  otp={otp}
306
325
  onOtpChange={setOtp}
@@ -310,32 +329,22 @@ function OtpLoginForm({
310
329
  onVerify={handleVerifyOtp}
311
330
  onResend={handleResendOtp}
312
331
  extraContent={
313
- isNewUser && authMethod !== "guest" ? (
332
+ isNewUser ? (
314
333
  <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>
334
+ <AuthField
335
+ label="First name"
336
+ name="first_name"
337
+ value={firstName}
338
+ onChange={(e) => setFirstName(e.target.value)}
339
+ required
340
+ />
341
+ <AuthField
342
+ label="Last name"
343
+ name="last_name"
344
+ value={lastName}
345
+ onChange={(e) => setLastName(e.target.value)}
346
+ required
347
+ />
339
348
  </div>
340
349
  ) : undefined
341
350
  }
@@ -344,68 +353,120 @@ function OtpLoginForm({
344
353
  )
345
354
  }
346
355
 
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
- }) {
356
+ export function AuthDivider({ label }: { label: string }) {
365
357
  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>
358
+ <div className="relative">
359
+ <div className="absolute inset-0 flex items-center">
360
+ <div className="w-full border-t border-brand-primary/10" />
361
+ </div>
362
+ <div className="relative flex justify-center items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.28em]">
363
+ <span className="px-2 bg-surface text-brand-accent">◆</span>
364
+ <span className="px-1 bg-surface text-muted">{label}</span>
365
+ <span className="px-2 bg-surface text-brand-accent">◆</span>
366
+ </div>
367
+ </div>
377
368
  )
378
369
  }
379
370
 
380
- function Field({
371
+ export function AuthField({
381
372
  label,
382
373
  name,
383
- type,
374
+ type = "text",
384
375
  autoComplete,
385
376
  value,
386
377
  onChange,
378
+ placeholder,
379
+ required = true,
387
380
  }: {
388
381
  label: string
389
- name: string
390
- type: string
382
+ name?: string
383
+ type?: string
391
384
  autoComplete?: string
392
385
  value?: string
393
386
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
387
+ placeholder?: string
388
+ required?: boolean
394
389
  }) {
395
390
  return (
396
391
  <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>
392
+ <span className={`${auth.fieldLabel} mb-1.5 block`}>{label}</span>
400
393
  <input
401
394
  name={name}
402
395
  type={type}
403
396
  autoComplete={autoComplete}
404
397
  value={value}
405
398
  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"
399
+ placeholder={placeholder}
400
+ required={required}
401
+ className={auth.input}
408
402
  />
409
403
  </label>
410
404
  )
411
405
  }
406
+
407
+ export function Ornament() {
408
+ return (
409
+ <div className="flex items-center justify-center gap-2 mb-3" aria-hidden>
410
+ <span className="h-px w-6 bg-brand-accent/70" />
411
+ <span className="text-brand-accent text-[7px] leading-none">◆</span>
412
+ <span className="h-px w-6 bg-brand-accent/70" />
413
+ </div>
414
+ )
415
+ }
416
+
417
+ function Chip({
418
+ active,
419
+ onClick,
420
+ children,
421
+ }: {
422
+ active: boolean
423
+ onClick: () => void
424
+ children: React.ReactNode
425
+ }) {
426
+ return (
427
+ <button
428
+ type="button"
429
+ onClick={onClick}
430
+ className={active ? auth.chipActive : auth.chipIdle}
431
+ >
432
+ {children}
433
+ </button>
434
+ )
435
+ }
436
+
437
+ function isNextRedirect(err: unknown) {
438
+ return (
439
+ typeof err === "object" &&
440
+ err !== null &&
441
+ "digest" in err &&
442
+ String((err as { digest?: string }).digest || "").includes("NEXT_REDIRECT")
443
+ )
444
+ }
445
+
446
+ function GuestPackageIcon() {
447
+ return (
448
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden>
449
+ <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" />
450
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" strokeLinecap="round" strokeLinejoin="round" />
451
+ <line x1="12" y1="22.08" x2="12" y2="12" strokeLinecap="round" />
452
+ </svg>
453
+ )
454
+ }
455
+
456
+ function EyeIcon() {
457
+ return (
458
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
459
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
460
+ <circle cx="12" cy="12" r="3" />
461
+ </svg>
462
+ )
463
+ }
464
+
465
+ function EyeOffIcon() {
466
+ return (
467
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
468
+ <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" />
469
+ <line x1="1" y1="1" x2="23" y2="23" />
470
+ </svg>
471
+ )
472
+ }