@pradip1995/commerce-auth 1.0.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,606 @@
1
+ "use client"
2
+
3
+ import { login, initiateGoogleAuth } from "@core/data/customer"
4
+ import { LOGIN_VIEW } from "@core/types/account"
5
+ import ErrorMessage from "@modules/checkout/components/error-message"
6
+ import Envelope from "@modules/common/icons/envelope"
7
+ import Lock from "@modules/common/icons/lock"
8
+ import Eye from "@modules/common/icons/eye"
9
+ import EyeOff from "@modules/common/icons/eye-off"
10
+ import Image from "next/image"
11
+ import { useEffect, useRef, useState } from "react"
12
+ import { useRouter, useParams, useSearchParams } from "next/navigation"
13
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
14
+ import { useServerAction } from "@core/hooks/use-server-action"
15
+
16
+ import { sendOTP, verifyOTP, listGuestOrders } from "@core/data/guest"
17
+ import { Button, Input, Heading, Text, clx } from "@medusajs/ui"
18
+ import Modal from "@modules/common/components/modal"
19
+ import { signout } from "@core/data/customer"
20
+ import DeletionPendingModal from "@modules/account/components/deletion-pending-modal"
21
+ import Spinner from "@modules/common/icons/spinner"
22
+
23
+ type Props = {
24
+ setCurrentView: (view: LOGIN_VIEW) => void
25
+ }
26
+
27
+ import PhoneInput from "react-phone-input-2"
28
+ import "react-phone-input-2/lib/style.css"
29
+ import Phone from "@modules/common/icons/phone"
30
+
31
+ const Login = ({ setCurrentView }: Props) => {
32
+ const router = useRouter()
33
+ const params = useParams()
34
+ const searchParams = useSearchParams()
35
+ const {
36
+ execute: submitLogin,
37
+ isPending,
38
+ result: message,
39
+ } = useServerAction((formData: FormData) => login(null, formData))
40
+ const [showPassword, setShowPassword] = useState(false)
41
+ const [loginMethod, setLoginMethod] = useState<"email" | "phone">("email")
42
+ const [googleLoading, setGoogleLoading] = useState(false)
43
+ const [showGuestModal, setShowGuestModal] = useState(false)
44
+ const [checkingGuest, setCheckingGuest] = useState(false)
45
+ const [showDeletionModal, setShowDeletionModal] = useState(false)
46
+ const [emailForCancellation, setEmailForCancellation] = useState("")
47
+ const emailRef = useRef<HTMLInputElement>(null)
48
+ const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
49
+ const [identifier, setIdentifier] = useState("")
50
+
51
+ useEffect(() => {
52
+ // Check if there's a stored redirect URL
53
+ const storedRedirect = localStorage.getItem("loginRedirectUrl")
54
+ if (storedRedirect) {
55
+ setRedirectUrl(storedRedirect)
56
+ }
57
+ }, [])
58
+
59
+ useEffect(() => {
60
+ // Handle account deletion pending error
61
+ if (message === "ACCOUNT_DELETION_PENDING") {
62
+ setEmailForCancellation(emailRef.current?.value || "")
63
+ setShowDeletionModal(true)
64
+ }
65
+ }, [message, params, router])
66
+
67
+ useEffect(() => {
68
+ const view = searchParams.get("view")
69
+ const email = searchParams.get("email")
70
+
71
+ if (view === "guest" && email && email !== "undefined") {
72
+ setShowGuestModal(true)
73
+ }
74
+ }, [searchParams])
75
+
76
+ const handleTrackGuestOrder = async () => {
77
+ const getCookie = (name: string) => {
78
+ if (typeof document === "undefined") return null
79
+ const value = `; ${document.cookie}`
80
+ const parts = value.split(`; ${name}=`)
81
+ if (parts.length === 2) return parts.pop()?.split(";").shift()
82
+ return null
83
+ }
84
+
85
+ const token = getCookie("_medusa_guest_token")
86
+
87
+ if (token) {
88
+ setCheckingGuest(true)
89
+ try {
90
+ // Verify token by trying to list orders
91
+ await listGuestOrders(token)
92
+
93
+ // If successful, redirect
94
+ const countryCode = (params?.countryCode as string) || "in"
95
+ router.push(`/${countryCode}/guest-orders`)
96
+ return
97
+ } catch (e) {
98
+ // Token invalid, clear it and show modal
99
+ document.cookie =
100
+ "_medusa_guest_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
101
+ } finally {
102
+ setCheckingGuest(false)
103
+ }
104
+ }
105
+
106
+ setShowGuestModal(true)
107
+ }
108
+
109
+ return (
110
+ <div className="w-full flex flex-col" data-testid="login-page">
111
+ <h1 className="text-xl min-[340px]:text-2xl min-[550px]:text-3xl font-bold text-heading mb-1 min-[340px]:mb-2 text-center min-[550px]:text-left">
112
+ Welcome Back
113
+ </h1>
114
+ <p className="text-sm min-[340px]:text-base text-gray-500 mb-6 text-center min-[550px]:text-left">
115
+ Log in to your account to continue
116
+ </p>
117
+
118
+ {/* Google Login at Top */}
119
+ <div className="mb-6">
120
+ <button
121
+ type="button"
122
+ onClick={async (e) => {
123
+ e.preventDefault()
124
+ e.stopPropagation()
125
+ try {
126
+ setGoogleLoading(true)
127
+
128
+ // Save current country code before redirecting to Google OAuth
129
+ const countryCode =
130
+ (params?.countryCode as string) ||
131
+ window.location.pathname.split("/")[1] ||
132
+ "us"
133
+ localStorage.setItem("googleLoginCountryCode", countryCode)
134
+
135
+ document.cookie =
136
+ "_medusa_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
137
+ document.cookie =
138
+ "_medusa_cache_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
139
+
140
+ // Use server-side action instead of direct backend call
141
+ const result = await initiateGoogleAuth(countryCode)
142
+
143
+ if (result.error) {
144
+ throw new Error(result.error)
145
+ }
146
+
147
+ const redirectUrl = result.redirectUrl
148
+
149
+ if (redirectUrl) {
150
+ window.location.href = redirectUrl
151
+ } else {
152
+ setGoogleLoading(false)
153
+ }
154
+ } catch (error) {
155
+ setGoogleLoading(false)
156
+ }
157
+ }}
158
+ disabled={googleLoading}
159
+ className="w-full flex items-center justify-center gap-2.5 py-3 px-4 border border-gray-200 bg-white hover:bg-gray-50 transition-all shadow-sm hover:shadow-md active:scale-[0.98]"
160
+ style={{ borderRadius: "30px" }}
161
+ >
162
+ <Image src="/Google.svg" alt="Google" width={20} height={20} />
163
+ <span className="text-sm text-gray-700 font-bold">
164
+ {googleLoading ? "Connecting..." : "Continue with Google"}
165
+ </span>
166
+ </button>
167
+
168
+ {/* Quick Link at Top */}
169
+ <div className="mt-4 text-center">
170
+ <span className="text-gray-700 text-xs min-[340px]:text-sm">
171
+ New to Chocomelon?{" "}
172
+ <button
173
+ onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
174
+ className="text-brand-accent font-bold hover:underline"
175
+ >
176
+ Register
177
+ </button>
178
+ </span>
179
+ </div>
180
+ </div>
181
+
182
+ <div className="relative mb-6">
183
+ <div className="absolute inset-0 flex items-center">
184
+ <div className="w-full border-t border-gray-100"></div>
185
+ </div>
186
+ <div className="relative flex justify-center text-xs uppercase tracking-widest">
187
+ <span className="px-3 bg-page-bg text-gray-400 font-bold">Or login with</span>
188
+ </div>
189
+ </div>
190
+ <form
191
+ className="w-full"
192
+ onSubmit={(event) => {
193
+ event.preventDefault()
194
+ submitLogin(new FormData(event.currentTarget))
195
+ }}
196
+ >
197
+ <input
198
+ type="hidden"
199
+ name="country_code"
200
+ value={(params?.countryCode as string) || "in"}
201
+ />
202
+ {redirectUrl && <input type="hidden" name="redirect_url" value={redirectUrl} />}
203
+
204
+ {/* Smart Identifier Input */}
205
+ <div className="mb-4">
206
+ {loginMethod === "email" ? (
207
+ <div className="relative group">
208
+ <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none transition-colors group-focus-within:text-brand-accent">
209
+ <Envelope size="20" />
210
+ </div>
211
+ <input
212
+ type="text"
213
+ name="email_or_phone"
214
+ ref={emailRef}
215
+ value={identifier}
216
+ placeholder="Email or Mobile Number"
217
+ required
218
+ autoFocus={loginMethod === "email"}
219
+ onChange={(e) => {
220
+ const value = e.target.value
221
+ setIdentifier(value)
222
+ const hasLetter = /[a-zA-Z]/.test(value)
223
+ const isNumeric = /^\+?[\d\s\-()]*$/.test(value) && value.length > 0
224
+
225
+ if (hasLetter) setLoginMethod("email")
226
+ else if (isNumeric) setLoginMethod("phone")
227
+ }}
228
+ className="w-full pl-12 pr-4 py-3 min-[550px]:py-3.5 text-sm min-[340px]:text-base border border-gray-200 bg-white focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent transition-all shadow-sm"
229
+ style={{ borderRadius: "30px" }}
230
+ />
231
+ </div>
232
+ ) : (
233
+ <div className="phone-input-login relative group">
234
+ <PhoneInput
235
+ country={"in"}
236
+ value={identifier}
237
+ onChange={(value) => {
238
+ setIdentifier(value)
239
+ const hasLetter = /[a-zA-Z]/.test(value)
240
+ if (hasLetter || value === "") setLoginMethod("email")
241
+ }}
242
+ inputProps={{
243
+ name: "email_or_phone",
244
+ required: true,
245
+ autoFocus: true,
246
+ }}
247
+ placeholder="Mobile Number"
248
+ containerStyle={{ width: "100%" }}
249
+ inputStyle={{
250
+ width: "100%",
251
+ height: "52px",
252
+ fontSize: "16px",
253
+ paddingLeft: "55px",
254
+ borderRadius: "30px",
255
+ border: "1px solid #e5e7eb",
256
+ backgroundColor: "var(--color-surface)",
257
+ }}
258
+ buttonStyle={{
259
+ backgroundColor: "transparent",
260
+ border: "none",
261
+ borderRadius: "30px 0 0 30px",
262
+ paddingLeft: "12px",
263
+ }}
264
+ dropdownStyle={{
265
+ width: "280px",
266
+ borderRadius: "15px",
267
+ zIndex: 50,
268
+ }}
269
+ />
270
+ <style jsx global>{`
271
+ .phone-input-login .react-tel-input .form-control:focus {
272
+ outline: none !important;
273
+ border-color: var(--color-brand-accent) !important;
274
+ box-shadow: 0 0 0 2px #f3e8ff !important;
275
+ }
276
+ `}</style>
277
+ </div>
278
+ )}
279
+ </div>
280
+
281
+ {/* Password Input */}
282
+ <div className="mb-4">
283
+ <div className="relative">
284
+ <div className="absolute inset-y-0 left-0 pl-3 min-[340px]:pl-4 flex items-center pointer-events-none">
285
+ <Lock size="20" color="#9CA3AF" />
286
+ </div>
287
+ <input
288
+ type={showPassword ? "text" : "password"}
289
+ name="password"
290
+ placeholder="Password"
291
+ autoComplete="current-password"
292
+ required
293
+ className="w-full pl-10 min-[340px]:pl-12 pr-10 min-[340px]:pr-12 py-2 min-[340px]:py-2.5 min-[550px]:py-3 text-sm min-[340px]:text-base border border-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-accent focus:border-transparent"
294
+ style={{ borderRadius: "30px" }}
295
+ data-testid="password-input"
296
+ />
297
+ <button
298
+ type="button"
299
+ onClick={() => setShowPassword(!showPassword)}
300
+ className="absolute inset-y-0 right-0 pr-3 min-[340px]:pr-4 flex items-center text-gray-500 hover:text-gray-700"
301
+ >
302
+ {showPassword ? <EyeOff size="20" /> : <Eye size="20" />}
303
+ </button>
304
+ </div>
305
+ </div>
306
+
307
+ {/* Forgot Password Link */}
308
+ <div className="flex justify-end mb-4 min-[340px]:mb-5 min-[550px]:mb-6">
309
+ <button
310
+ type="button"
311
+ onClick={() => setCurrentView(LOGIN_VIEW.FORGOT_PASSWORD)}
312
+ className="text-xs min-[340px]:text-sm text-gray-700 hover:text-brand-accent"
313
+ >
314
+ Forgot password?
315
+ </button>
316
+ </div>
317
+
318
+ <ErrorMessage
319
+ error={message === "ACCOUNT_DELETION_PENDING" ? "" : message}
320
+ data-testid="login-error-message"
321
+ />
322
+
323
+ {/* Terms and Conditions */}
324
+ <div className="mb-4 min-[340px]:mb-5 min-[550px]:mb-6">
325
+ <span className="text-center text-gray-700 text-xs min-[340px]:text-sm">
326
+ By logging in, you agree to Chocomelon&apos;s{" "}
327
+ <LocalizedClientLink
328
+ href="/privacy-policy"
329
+ className="text-brand-accent font-semibold hover:underline"
330
+ >
331
+ Privacy Policy
332
+ </LocalizedClientLink>{" "}
333
+ and{" "}
334
+ <LocalizedClientLink
335
+ href="/terms-of-use"
336
+ className="text-brand-accent font-semibold hover:underline"
337
+ >
338
+ Terms of Use
339
+ </LocalizedClientLink>
340
+ .
341
+ </span>
342
+ </div>
343
+
344
+ {/* Login Button */}
345
+ <button
346
+ type="submit"
347
+ disabled={isPending}
348
+ className="w-full py-2.5 min-[340px]:py-3 px-3 min-[340px]:px-4 bg-brand-accent text-sm min-[340px]:text-base text-inverse font-bold hover:bg-brand-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed mb-4 min-[340px]:mb-5 min-[550px]:mb-6"
349
+ style={{ borderRadius: "30px" }}
350
+ onClick={() => {
351
+ if (typeof window !== "undefined") {
352
+ // Wait slightly to make sure the form value is captured,
353
+ // though React state is already set.
354
+ setTimeout(() => {
355
+ localStorage.removeItem("loginRedirectUrl")
356
+ }, 100)
357
+ }
358
+ }}
359
+ data-testid="sign-in-button"
360
+ >
361
+ {isPending ? "Logging in..." : "Login"}
362
+ </button>
363
+ </form>
364
+
365
+ {/* Guest Order Tracking Button */}
366
+
367
+ {/* Guest Order Tracking Button */}
368
+ <div className="mt-6 pt-6 border-t border-gray-200">
369
+ <button
370
+ type="button"
371
+ onClick={handleTrackGuestOrder}
372
+ disabled={checkingGuest}
373
+ className="w-full py-2.5 px-4 rounded-full border border-brand-accent text-brand-accent text-sm font-bold hover:bg-brand-accent-muted transition-colors flex items-center justify-center gap-2"
374
+ >
375
+ {checkingGuest ? <Spinner className="animate-spin" /> : <span>📦</span>}
376
+ {checkingGuest ? "Checking..." : "Track Guest Order"}
377
+ </button>
378
+ </div>
379
+
380
+ <DeletionPendingModal
381
+ isOpen={showDeletionModal}
382
+ close={() => setShowDeletionModal(false)}
383
+ email={emailForCancellation}
384
+ countryCode={params.countryCode as string}
385
+ />
386
+
387
+ <GuestOrderModal
388
+ isOpen={showGuestModal}
389
+ close={() => setShowGuestModal(false)}
390
+ router={router}
391
+ params={params}
392
+ initialEmail={searchParams.get("email") || ""}
393
+ autoStart={searchParams.get("view") === "guest" && !!searchParams.get("email")}
394
+ />
395
+ </div>
396
+ )
397
+ }
398
+
399
+ const GuestOrderModal = ({
400
+ isOpen,
401
+ close,
402
+ router,
403
+ params,
404
+ initialEmail = "",
405
+ autoStart = false,
406
+ }: {
407
+ isOpen: boolean
408
+ close: () => void
409
+ router: any
410
+ params: any
411
+ initialEmail?: string
412
+ autoStart?: boolean
413
+ }) => {
414
+ const [email, setEmail] = useState(initialEmail)
415
+ const [otp, setOtp] = useState("")
416
+ const [step, setStep] = useState<"email" | "otp">("email")
417
+ const [isLoading, setIsLoading] = useState(false)
418
+ const [error, setError] = useState<string | null>(null)
419
+ const [verificationToken, setVerificationToken] = useState<string | null>(null)
420
+ const [success, setSuccess] = useState(false)
421
+ const autoStartedRef = useRef(false)
422
+
423
+ useEffect(() => {
424
+ if (initialEmail) setEmail(initialEmail)
425
+ }, [initialEmail])
426
+
427
+ useEffect(() => {
428
+ // If autoStart is true and we have an email, trigger OTP send immediately
429
+ if (isOpen && autoStart && initialEmail && !autoStartedRef.current) {
430
+ autoStartedRef.current = true
431
+ setIsLoading(true)
432
+ handleSendOtp(initialEmail)
433
+ }
434
+ }, [isOpen, autoStart, initialEmail])
435
+
436
+ const handleSendOtp = async (emailToSend = email) => {
437
+ if (!emailToSend) return
438
+ setIsLoading(true)
439
+ setError(null)
440
+
441
+ try {
442
+ const res = (await sendOTP(emailToSend, "email_verification")) as any
443
+ if (res.token || res.success !== false) {
444
+ setVerificationToken(res.token || "temp-token")
445
+ setStep("otp")
446
+ } else {
447
+ setError((res as any).error || "Failed to send OTP")
448
+ setStep("email")
449
+ }
450
+ } catch (e: any) {
451
+ setError(e.message)
452
+ setStep("email")
453
+ } finally {
454
+ setIsLoading(false)
455
+ }
456
+ }
457
+
458
+ const handleVerifyOtp = async () => {
459
+ if (!otp) return
460
+ setIsLoading(true)
461
+ setError(null)
462
+
463
+ try {
464
+ // Pass email as customerId since it's a guest flow
465
+ const res = (await verifyOTP(email, verificationToken || "", otp)) as any
466
+ if (res.success !== false) {
467
+ // Assuming success if no explicit error field
468
+ setSuccess(true)
469
+
470
+ // Store token in cookie
471
+ // Note: For a real app, use a server action to set httpOnly cookie.
472
+ // Here we simulate for client-side usage or basic auth header.
473
+ const token = res.token || "guest_access_token"
474
+ document.cookie = `_medusa_guest_token=${token}; path=/; max-age=3600; SameSite=Lax`
475
+
476
+ // Redirect to guest order view
477
+ const countryCode = (params?.countryCode as string) || "in"
478
+ router.push(`/${countryCode}/guest-orders`)
479
+ } else {
480
+ setError(res.error || "Invalid OTP")
481
+ }
482
+ } catch (e: any) {
483
+ setError(e.message)
484
+ } finally {
485
+ setIsLoading(false)
486
+ }
487
+ }
488
+
489
+ const reset = () => {
490
+ setEmail("")
491
+ setOtp("")
492
+ setStep("email")
493
+ setError(null)
494
+ setSuccess(false)
495
+ close()
496
+ }
497
+
498
+ return (
499
+ <Modal isOpen={isOpen} close={reset} size="small">
500
+ <Modal.Body>
501
+ <div className="flex flex-col gap-6 py-4 px-2">
502
+ {/* Header */}
503
+ <div className="text-center">
504
+ <h2 className="text-2xl font-bold text-heading mb-2">
505
+ {step === "email" ? "Track Your Order" : "Verify OTP"}
506
+ </h2>
507
+ <p className="text-sm text-gray-500 leading-relaxed max-w-[300px] mx-auto">
508
+ {step === "email"
509
+ ? "Enter your email address to track your guest orders. We will send you a One-Time Password (OTP)."
510
+ : `Enter the 6-digit code sent to ${email}`}
511
+ </p>
512
+ </div>
513
+
514
+ {success ? (
515
+ <div className="flex flex-col items-center gap-4 py-6">
516
+ <div className="text-brand-accent animate-pulse font-bold">
517
+ Verification Successful!
518
+ </div>
519
+ <Spinner />
520
+ </div>
521
+ ) : step === "email" ? (
522
+ <div className="space-y-4">
523
+ <div className="relative">
524
+ <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-gray-400">
525
+ <Envelope size="20" />
526
+ </div>
527
+ <input
528
+ placeholder="Email"
529
+ value={email}
530
+ onChange={(e) => setEmail(e.target.value)}
531
+ autoComplete="email"
532
+ type="email"
533
+ required
534
+ className="w-full pl-12 pr-4 py-3 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-accent transition-all text-sm"
535
+ style={{ borderRadius: "30px" }}
536
+ />
537
+ </div>
538
+
539
+ {error && <p className="text-red-500 text-xs text-center">{error}</p>}
540
+
541
+ <button
542
+ className="w-full py-3 bg-brand-accent text-inverse font-bold hover:bg-brand-accent-hover transition-colors flex justify-center items-center gap-2"
543
+ style={{ borderRadius: "30px" }}
544
+ onClick={() => handleSendOtp()}
545
+ disabled={isLoading || !email}
546
+ >
547
+ {isLoading ? <Spinner size={16} /> : "Send OTP"}
548
+ </button>
549
+ </div>
550
+ ) : (
551
+ <div className="space-y-4">
552
+ <div className="relative flex justify-center gap-x-2.5 mt-6">
553
+ {[0, 1, 2, 3, 4, 5].map((index) => (
554
+ <div
555
+ key={index}
556
+ className={`w-10 h-12 flex items-center justify-center border-2 rounded-lg text-xl font-bold transition-all duration-200 ${
557
+ otp.length === index
558
+ ? "border-brand-accent ring-2 ring-brand-accent/10 bg-page-bg"
559
+ : otp.length > index
560
+ ? "border-gray-400 bg-page-bg shadow-sm"
561
+ : "border-gray-200 bg-gray-50/50"
562
+ }`}
563
+ >
564
+ {otp[index] || ""}
565
+ </div>
566
+ ))}
567
+ <input
568
+ type="text"
569
+ maxLength={6}
570
+ className="absolute inset-0 opacity-0 cursor-pointer"
571
+ value={otp}
572
+ onChange={(e) =>
573
+ setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))
574
+ }
575
+ autoFocus
576
+ />
577
+ </div>
578
+
579
+ {error && <p className="text-red-500 text-xs text-center">{error}</p>}
580
+
581
+ <div className="space-y-3">
582
+ <button
583
+ className="w-full py-3 bg-brand-accent text-inverse font-bold hover:bg-brand-accent-hover transition-colors flex justify-center items-center gap-2"
584
+ style={{ borderRadius: "30px" }}
585
+ onClick={handleVerifyOtp}
586
+ disabled={isLoading || otp.length < 6}
587
+ >
588
+ {isLoading ? <Spinner size={16} /> : "Verify & View Orders"}
589
+ </button>
590
+
591
+ <button
592
+ onClick={() => setStep("email")}
593
+ className="text-xs text-gray-500 hover:text-brand-accent w-full text-center underline font-medium"
594
+ >
595
+ Change Email
596
+ </button>
597
+ </div>
598
+ </div>
599
+ )}
600
+ </div>
601
+ </Modal.Body>
602
+ </Modal>
603
+ )
604
+ }
605
+
606
+ export default Login