@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,581 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useParams, useRouter } from "next/navigation"
5
+ import { LOGIN_VIEW } from "@core/types/account"
6
+ import ErrorMessage from "@modules/checkout/components/error-message"
7
+ import Envelope from "@modules/common/icons/envelope"
8
+ import Lock from "@modules/common/icons/lock"
9
+ import User from "@modules/common/icons/user"
10
+ import PhoneIcon from "@modules/common/icons/phone"
11
+ import Eye from "@modules/common/icons/eye"
12
+ import EyeOff from "@modules/common/icons/eye-off"
13
+ import Image from "next/image"
14
+ import { initiateGoogleAuth } from "@core/data/customer"
15
+ import {
16
+ registerCustomer,
17
+ sendCustomerOTP,
18
+ verifyCustomerOTP,
19
+ } from "@core/data/customer-registration"
20
+ import LocalizedClientLink from "@modules/common/components/localized-client-link"
21
+ import { Input, Button, Heading, Text, clx } from "@medusajs/ui"
22
+ import Modal from "@modules/common/components/modal"
23
+ import PhoneInput from "react-phone-input-2"
24
+ import "react-phone-input-2/lib/style.css"
25
+ import Spinner from "@modules/common/icons/spinner"
26
+ import { useServerAction } from "@core/hooks/use-server-action"
27
+
28
+ type Props = {
29
+ setCurrentView: (view: LOGIN_VIEW) => void
30
+ }
31
+
32
+ type RegistrationStep = "form" | "otp" | "success"
33
+
34
+ type RegistrationFormData = {
35
+ full_name: string
36
+ email: string
37
+ phone: string
38
+ password: string
39
+ verificationMethod: "email_verification" | "phone_verification"
40
+ }
41
+
42
+ const Register = ({ setCurrentView }: Props) => {
43
+ const params = useParams()
44
+ const router = useRouter()
45
+ const [showPassword, setShowPassword] = useState(false)
46
+ const [googleLoading, setGoogleLoading] = useState(false)
47
+ const [error, setError] = useState<string | null>(null)
48
+
49
+ // Multi-step registration state
50
+ const [step, setStep] = useState<RegistrationStep>("form")
51
+ const [formData, setFormData] = useState({
52
+ full_name: "",
53
+ email: "",
54
+ phone: "",
55
+ password: "",
56
+ })
57
+ const [customerId, setCustomerId] = useState<string | null>(null)
58
+ const [otpToken, setOtpToken] = useState<string | null>(null)
59
+ const [otp, setOtp] = useState("")
60
+ const [showOtpModal, setShowOtpModal] = useState(false)
61
+ const [verificationMethod, setVerificationMethod] = useState<
62
+ "email_verification" | "phone_verification"
63
+ >("phone_verification")
64
+
65
+ const {
66
+ execute: submitRegistration,
67
+ isPending: isSubmitting,
68
+ error: submitError,
69
+ } = useServerAction(async (payload: RegistrationFormData) => {
70
+ const [first_name, ...last_name_parts] = payload.full_name.split(" ")
71
+ const last_name = last_name_parts.join(" ") || "."
72
+
73
+ if (payload.verificationMethod === "phone_verification" && !payload.phone) {
74
+ throw new Error("Please enter your phone number for mobile verification")
75
+ }
76
+
77
+ const result = await registerCustomer({
78
+ email: payload.email,
79
+ first_name: first_name || "",
80
+ last_name: last_name,
81
+ phone: payload.phone || undefined,
82
+ password: payload.password,
83
+ })
84
+
85
+ if (!result.success) {
86
+ throw new Error(result.error || "Registration failed")
87
+ }
88
+
89
+ setCustomerId(result.customer?.id ?? null)
90
+
91
+ const otpResult = await sendCustomerOTP(
92
+ result.customer?.id,
93
+ payload.verificationMethod
94
+ )
95
+
96
+ if (!otpResult.success) {
97
+ throw new Error(otpResult.error || "Failed to send verification code")
98
+ }
99
+
100
+ setOtpToken(otpResult.token ?? null)
101
+ setShowOtpModal(true)
102
+ setStep("otp")
103
+ })
104
+
105
+ const {
106
+ execute: verifyRegistrationOtp,
107
+ isPending: otpLoading,
108
+ error: verifyError,
109
+ } = useServerAction(async (payload: { token: string; otp: string }) => {
110
+ const result = await verifyCustomerOTP(payload.token, payload.otp)
111
+
112
+ if (!result.success) {
113
+ throw new Error(result.error || "Invalid verification code")
114
+ }
115
+
116
+ setStep("success")
117
+
118
+ setTimeout(() => {
119
+ setShowOtpModal(false)
120
+ setCurrentView(LOGIN_VIEW.SIGN_IN)
121
+ }, 2000)
122
+ })
123
+
124
+ const {
125
+ execute: resendRegistrationOtp,
126
+ isPending: isResendingOtp,
127
+ error: resendError,
128
+ } = useServerAction(
129
+ async (payload: {
130
+ customerId: string
131
+ method: "email_verification" | "phone_verification"
132
+ }) => {
133
+ const result = await sendCustomerOTP(payload.customerId, payload.method)
134
+
135
+ if (!result.success) {
136
+ throw new Error(result.error || "Failed to resend code")
137
+ }
138
+
139
+ setOtpToken(result.token ?? null)
140
+ }
141
+ )
142
+
143
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
144
+ setFormData((prev) => ({
145
+ ...prev,
146
+ [e.target.name]: e.target.value,
147
+ }))
148
+ }
149
+
150
+ const handleFormSubmit = (e: React.FormEvent) => {
151
+ e.preventDefault()
152
+ setError(null)
153
+ submitRegistration({
154
+ ...formData,
155
+ verificationMethod,
156
+ })
157
+ }
158
+
159
+ const handleVerifyOtp = () => {
160
+ if (!otp || otp.length < 6) return
161
+ setError(null)
162
+ verifyRegistrationOtp({
163
+ token: otpToken || "",
164
+ otp,
165
+ })
166
+ }
167
+
168
+ const handleResendOtp = () => {
169
+ if (!customerId) return
170
+ setError(null)
171
+ resendRegistrationOtp({
172
+ customerId,
173
+ method: verificationMethod,
174
+ })
175
+ }
176
+
177
+ const actionError =
178
+ error || submitError?.message || verifyError?.message || resendError?.message
179
+
180
+ const closeOtpModal = () => {
181
+ setShowOtpModal(false)
182
+ setOtp("")
183
+ setError(null)
184
+ }
185
+
186
+ return (
187
+ <div className="w-full flex flex-col" data-testid="register-page">
188
+ {/* Register Title */}
189
+ <h1 className="text-xl min-[340px]:text-2xl min-[550px]:text-3xl font-bold text-heading mb-1 min-[340px]:mb-2">
190
+ Register to Chocomelon
191
+ </h1>
192
+ <p className="text-sm min-[340px]:text-base text-gray-500 mb-6 text-center min-[550px]:text-left">
193
+ Create an account! Please enter your details
194
+ </p>
195
+
196
+ {/* Google Signup at Top */}
197
+ <div className="mb-6">
198
+ <button
199
+ type="button"
200
+ onClick={async (e) => {
201
+ e.preventDefault()
202
+ e.stopPropagation()
203
+ try {
204
+ setGoogleLoading(true)
205
+
206
+ // Save current country code before redirecting to Google OAuth
207
+ const countryCode =
208
+ (params?.countryCode as string) ||
209
+ window.location.pathname.split("/")[1] ||
210
+ "us"
211
+ localStorage.setItem("googleLoginCountryCode", countryCode)
212
+
213
+ document.cookie =
214
+ "_medusa_jwt=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
215
+ document.cookie =
216
+ "_medusa_cache_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"
217
+
218
+ // Use server-side action instead of direct backend call
219
+ const result = await initiateGoogleAuth(countryCode)
220
+
221
+ if (result.error) {
222
+ throw new Error(result.error)
223
+ }
224
+
225
+ const redirectUrl = result.redirectUrl
226
+
227
+ if (redirectUrl) {
228
+ window.location.href = redirectUrl
229
+ } else {
230
+ setGoogleLoading(false)
231
+ }
232
+ } catch (error) {
233
+ setGoogleLoading(false)
234
+ }
235
+ }}
236
+ disabled={googleLoading}
237
+ 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]"
238
+ style={{ borderRadius: "30px" }}
239
+ >
240
+ <Image src="/Google.svg" alt="Google" width={20} height={20} />
241
+ <span className="text-sm text-gray-700 font-bold">
242
+ {googleLoading ? "Connecting..." : "Continue with Google"}
243
+ </span>
244
+ </button>
245
+
246
+ {/* Quick Link at Top */}
247
+ <div className="mt-4 text-center">
248
+ <span className="text-gray-700 text-xs min-[340px]:text-sm">
249
+ Already have an account?{" "}
250
+ <button
251
+ onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
252
+ className="text-brand-accent font-bold hover:underline"
253
+ >
254
+ Login
255
+ </button>
256
+ </span>
257
+ </div>
258
+ </div>
259
+
260
+ {/* Registration Toggle */}
261
+ <div className="flex justify-center mb-8">
262
+ <div className="flex bg-[#F3F4F6] p-1.5 rounded-full w-full max-w-[420px] border border-gray-100 shadow-inner">
263
+ <button
264
+ type="button"
265
+ onClick={() => setVerificationMethod("phone_verification")}
266
+ className={clx(
267
+ "flex-1 py-2.5 text-sm font-bold rounded-full transition-all duration-300",
268
+ verificationMethod === "phone_verification"
269
+ ? "bg-white text-brand-accent shadow-md border-2 border-black"
270
+ : "text-gray-400 hover:text-gray-600"
271
+ )}
272
+ >
273
+ Mobile Registration
274
+ </button>
275
+ <button
276
+ type="button"
277
+ onClick={() => setVerificationMethod("email_verification")}
278
+ className={clx(
279
+ "flex-1 py-2.5 text-sm font-bold rounded-full transition-all duration-300",
280
+ verificationMethod === "email_verification"
281
+ ? "bg-white text-brand-accent shadow-md border-2 border-black"
282
+ : "text-gray-400 hover:text-gray-600"
283
+ )}
284
+ >
285
+ Email Registration
286
+ </button>
287
+ </div>
288
+ </div>
289
+
290
+ <div className="relative mb-6">
291
+ <div className="absolute inset-0 flex items-center">
292
+ <div className="w-full border-t border-gray-100"></div>
293
+ </div>
294
+ <div className="relative flex justify-center text-xs uppercase tracking-widest">
295
+ <span className="px-3 bg-page-bg text-gray-400 font-bold">
296
+ Or sign up with
297
+ </span>
298
+ </div>
299
+ </div>
300
+
301
+ <form className="w-full" onSubmit={handleFormSubmit}>
302
+ {/* Full Name Input */}
303
+ <div className="mb-4">
304
+ <div className="relative">
305
+ <div className="absolute inset-y-0 left-0 pl-3 min-[340px]:pl-4 flex items-center pointer-events-none">
306
+ <User size="20" color="#9CA3AF" />
307
+ </div>
308
+ <input
309
+ type="text"
310
+ name="full_name"
311
+ placeholder="Full name"
312
+ autoComplete="name"
313
+ required
314
+ value={formData.full_name}
315
+ onChange={handleInputChange}
316
+ className="w-full pl-10 min-[340px]:pl-12 pr-3 min-[340px]:pr-4 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"
317
+ style={{ borderRadius: "30px" }}
318
+ data-testid="full-name-input"
319
+ />
320
+ </div>
321
+ </div>
322
+
323
+ {/* Email Input */}
324
+ <div className="mb-4">
325
+ <div className="relative">
326
+ <div className="absolute inset-y-0 left-0 pl-3 min-[340px]:pl-4 flex items-center pointer-events-none">
327
+ <Envelope size="20" color="#9CA3AF" />
328
+ </div>
329
+ <input
330
+ type="email"
331
+ name="email"
332
+ placeholder="Email"
333
+ autoComplete="email"
334
+ required
335
+ value={formData.email}
336
+ onChange={handleInputChange}
337
+ className="w-full pl-10 min-[340px]:pl-12 pr-3 min-[340px]:pr-4 py-2 min-[340px]:py-2.5 min-[550px]:py-3 text-sm min-[340px]:text-base border border-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent transition-all"
338
+ style={{ borderRadius: "30px" }}
339
+ data-testid="email-input"
340
+ />
341
+ </div>
342
+ </div>
343
+
344
+ {/* Phone Input */}
345
+ <div className="mb-4 phone-input-register">
346
+ <PhoneInput
347
+ country={"in"}
348
+ value={formData.phone}
349
+ onChange={(value) => {
350
+ setFormData((prev) => ({
351
+ ...prev,
352
+ phone: value.startsWith("+") ? value : `+${value}`,
353
+ }))
354
+ }}
355
+ inputProps={{
356
+ name: "phone",
357
+ required: verificationMethod === "phone_verification",
358
+ autoComplete: "tel",
359
+ }}
360
+ placeholder="Mobile Number"
361
+ containerStyle={{
362
+ width: "100%",
363
+ }}
364
+ inputStyle={{
365
+ width: "100%",
366
+ height: "46px",
367
+ fontSize: "15px",
368
+ paddingLeft: "55px",
369
+ borderRadius: "30px",
370
+ border: "1px solid #d1d5db",
371
+ backgroundColor: "var(--color-surface)",
372
+ transition: "all 0.2s",
373
+ }}
374
+ buttonStyle={{
375
+ backgroundColor: "transparent",
376
+ border: "none",
377
+ borderRadius: "30px 0 0 30px",
378
+ paddingLeft: "12px",
379
+ }}
380
+ dropdownStyle={{
381
+ width: "280px",
382
+ borderRadius: "15px",
383
+ boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
384
+ zIndex: 50,
385
+ }}
386
+ />
387
+ <style jsx global>{`
388
+ .phone-input-register .react-tel-input .form-control:focus {
389
+ outline: none !important;
390
+ ring: 2px !important;
391
+ border-color: var(--color-brand-accent) !important;
392
+ box-shadow: 0 0 0 2px #f3e8ff !important;
393
+ }
394
+ `}</style>
395
+ </div>
396
+
397
+ {/* Password Input */}
398
+ <div className="mb-4">
399
+ <div className="relative">
400
+ <div className="absolute inset-y-0 left-0 pl-3 min-[340px]:pl-4 flex items-center pointer-events-none">
401
+ <Lock size="20" color="#9CA3AF" />
402
+ </div>
403
+ <input
404
+ type={showPassword ? "text" : "password"}
405
+ name="password"
406
+ placeholder="Password"
407
+ autoComplete="new-password"
408
+ required
409
+ value={formData.password}
410
+ onChange={handleInputChange}
411
+ 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"
412
+ style={{ borderRadius: "30px" }}
413
+ data-testid="password-input"
414
+ />
415
+ <button
416
+ type="button"
417
+ onClick={() => setShowPassword(!showPassword)}
418
+ className="absolute inset-y-0 right-0 pr-3 min-[340px]:pr-4 flex items-center text-gray-500 hover:text-gray-700"
419
+ >
420
+ {showPassword ? <EyeOff size="20" /> : <Eye size="20" />}
421
+ </button>
422
+ </div>
423
+ </div>
424
+
425
+ {/* Error Message */}
426
+ {actionError && !showOtpModal && (
427
+ <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
428
+ <p className="text-red-600 text-sm">{actionError}</p>
429
+ </div>
430
+ )}
431
+
432
+ {/* Terms and Conditions */}
433
+ <div className="mb-4 min-[340px]:mb-5 min-[550px]:mb-6">
434
+ <span className="text-center text-gray-700 text-xs min-[340px]:text-sm">
435
+ By creating an account, you agree to Chocomelon&apos;s{" "}
436
+ <LocalizedClientLink
437
+ href="/privacy-policy"
438
+ className="text-brand-accent font-semibold hover:underline"
439
+ >
440
+ Privacy Policy
441
+ </LocalizedClientLink>{" "}
442
+ and{" "}
443
+ <LocalizedClientLink
444
+ href="/terms-of-use"
445
+ className="text-brand-accent font-semibold hover:underline"
446
+ >
447
+ Terms of Use
448
+ </LocalizedClientLink>
449
+ .
450
+ </span>
451
+ </div>
452
+
453
+ {/* Register Button */}
454
+ <button
455
+ type="submit"
456
+ disabled={isSubmitting}
457
+ 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"
458
+ style={{ borderRadius: "30px" }}
459
+ data-testid="register-button"
460
+ >
461
+ {isSubmitting ? "Registering..." : "Register"}
462
+ </button>
463
+ </form>
464
+
465
+ {/* OTP Verification Modal */}
466
+
467
+ {/* OTP Verification Modal */}
468
+ <Modal isOpen={showOtpModal} close={closeOtpModal} size="small">
469
+ <Modal.Title>
470
+ <Heading className="text-xl font-bold text-center">
471
+ {step === "success"
472
+ ? "Account Verified! 🎉"
473
+ : verificationMethod === "email_verification"
474
+ ? "Verify Your Email"
475
+ : "Verify Your Mobile"}
476
+ </Heading>
477
+ </Modal.Title>
478
+ <Modal.Body>
479
+ <div className="flex flex-col gap-4 py-4">
480
+ {step === "success" ? (
481
+ <div className="flex flex-col items-center text-center py-6 animate-in zoom-in duration-300">
482
+ <div className="w-20 h-20 bg-green-50 rounded-full flex items-center justify-center mb-6 shadow-sm border border-green-100">
483
+ <svg
484
+ xmlns="http://www.w3.org/2000/svg"
485
+ width="32"
486
+ height="32"
487
+ viewBox="0 0 24 24"
488
+ fill="none"
489
+ stroke="#10b981"
490
+ strokeWidth="3"
491
+ strokeLinecap="round"
492
+ strokeLinejoin="round"
493
+ >
494
+ <polyline points="20 6 9 17 4 12"></polyline>
495
+ </svg>
496
+ </div>
497
+ <Heading level="h2" className="text-2xl font-bold text-heading mb-2">
498
+ Account Verified! 🎉
499
+ </Heading>
500
+ <Text className="text-gray-600">
501
+ Your account has been verified successfully!
502
+ </Text>
503
+ <div className="flex items-center gap-3 mt-8 bg-gray-50 px-4 py-2 rounded-full border border-gray-100">
504
+ <Spinner className="animate-spin text-brand-accent" />
505
+ <Text className="text-gray-500 text-sm font-medium">
506
+ Redirecting to login...
507
+ </Text>
508
+ </div>
509
+ </div>
510
+ ) : (
511
+ <>
512
+ <Text className="text-gray-500 text-center text-sm">
513
+ We&apos;ve sent a 6-digit verification code to{" "}
514
+ <strong>
515
+ {verificationMethod === "email_verification"
516
+ ? formData.email
517
+ : formData.phone}
518
+ </strong>
519
+ </Text>
520
+
521
+ <div className="relative flex justify-center gap-x-2.5 mt-8">
522
+ {[0, 1, 2, 3, 4, 5].map((index) => (
523
+ <div
524
+ key={index}
525
+ className={`w-10 h-12 flex items-center justify-center border-2 rounded-lg text-xl font-bold transition-all duration-200 ${
526
+ otp.length === index
527
+ ? "border-brand-accent ring-2 ring-brand-accent/10 bg-page-bg"
528
+ : otp.length > index
529
+ ? "border-gray-400 bg-page-bg shadow-sm"
530
+ : "border-gray-200 bg-gray-50/50"
531
+ }`}
532
+ >
533
+ {otp[index] || ""}
534
+ </div>
535
+ ))}
536
+ <input
537
+ type="text"
538
+ maxLength={6}
539
+ className="absolute inset-0 opacity-0 cursor-pointer"
540
+ value={otp}
541
+ onChange={(e) =>
542
+ setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))
543
+ }
544
+ autoFocus
545
+ />
546
+ </div>
547
+
548
+ {actionError && (
549
+ <Text className="text-red-500 text-sm text-center">
550
+ {actionError}
551
+ </Text>
552
+ )}
553
+
554
+ <Button
555
+ className="w-full bg-brand-accent hover:bg-brand-accent-hover text-inverse"
556
+ onClick={handleVerifyOtp}
557
+ isLoading={otpLoading || isResendingOtp}
558
+ disabled={otp.length < 6 || otpLoading || isResendingOtp}
559
+ >
560
+ Verify & Continue
561
+ </Button>
562
+
563
+ <div className="text-center">
564
+ <button
565
+ onClick={handleResendOtp}
566
+ disabled={otpLoading || isResendingOtp}
567
+ className="text-xs text-gray-500 underline hover:text-gray-700 disabled:opacity-50"
568
+ >
569
+ Didn&apos;t receive code? Resend
570
+ </button>
571
+ </div>
572
+ </>
573
+ )}
574
+ </div>
575
+ </Modal.Body>
576
+ </Modal>
577
+ </div>
578
+ )
579
+ }
580
+
581
+ export default Register