@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,307 @@
1
+ "use server"
2
+
3
+ import {
4
+ checkEmailRegistered,
5
+ completePasswordReset,
6
+ registerCustomer as registerCustomerBase,
7
+ requestPasswordReset,
8
+ sendCustomerOTP,
9
+ verifyCustomerOTP,
10
+ } from "@pradip1995/commerce-core/data/customer-registration"
11
+ import { transferCart } from "@pradip1995/commerce-core/data/customer"
12
+ import { getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
13
+ import { sendOTP, verifyOTP } from "@pradip1995/commerce-core/data/guest"
14
+ import { getGoogleOAuthCallbackUrl } from "@pradip1995/commerce-core/util/google-oauth"
15
+ import { revalidateTag } from "next/cache"
16
+ import { redirect } from "next/navigation"
17
+
18
+ export {
19
+ sendCustomerOTP,
20
+ verifyCustomerOTP,
21
+ requestPasswordReset,
22
+ checkEmailRegistered,
23
+ completePasswordReset,
24
+ sendOTP,
25
+ verifyOTP,
26
+ }
27
+
28
+ function normalizeRegistrationError(message: string): string {
29
+ const lower = message.toLowerCase()
30
+ if (
31
+ lower.includes("identity with email already exists") ||
32
+ lower.includes("email already exists") ||
33
+ lower.includes("email is already registered")
34
+ ) {
35
+ return "Email already exists"
36
+ }
37
+ if (
38
+ lower.includes("identity with phone already exists") ||
39
+ lower.includes("phone already exists")
40
+ ) {
41
+ return "Phone number already exists"
42
+ }
43
+ return message
44
+ }
45
+
46
+ export async function registerCustomer(data: {
47
+ email: string
48
+ first_name: string
49
+ last_name: string
50
+ phone?: string
51
+ password: string
52
+ }) {
53
+ const result = await registerCustomerBase(data)
54
+ if (!result.success && result.error) {
55
+ return { ...result, error: normalizeRegistrationError(result.error) }
56
+ }
57
+ return result
58
+ }
59
+
60
+ function getHeaders() {
61
+ return {
62
+ "Content-Type": "application/json",
63
+ "x-publishable-api-key": process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || "",
64
+ }
65
+ }
66
+
67
+ function getBaseUrl() {
68
+ return process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
69
+ }
70
+
71
+ export async function sendAuthOtp(input: {
72
+ email?: string
73
+ phone?: string
74
+ type: "email_auth" | "phone_auth"
75
+ }) {
76
+ const baseUrl = getBaseUrl()
77
+ const body =
78
+ input.type === "email_auth"
79
+ ? { email: input.email?.toLowerCase().trim(), type: input.type }
80
+ : { phone: input.phone?.replace(/[^\d+]/g, ""), type: input.type }
81
+
82
+ const response = await fetch(`${baseUrl}/store/customers/otp/send`, {
83
+ method: "POST",
84
+ headers: getHeaders(),
85
+ body: JSON.stringify(body),
86
+ })
87
+
88
+ if (!response.ok) {
89
+ const err = await response.json().catch(() => ({}))
90
+ return {
91
+ success: false as const,
92
+ error: (err as { message?: string }).message || "Failed to send verification code",
93
+ }
94
+ }
95
+
96
+ const data = (await response.json()) as {
97
+ token?: string
98
+ expires_at?: string
99
+ is_new_user?: boolean
100
+ }
101
+
102
+ return {
103
+ success: true as const,
104
+ token: data.token,
105
+ isNewUser: data.is_new_user ?? false,
106
+ }
107
+ }
108
+
109
+ export async function verifyAuthOtpAndLogin(input: {
110
+ token: string
111
+ code: string
112
+ countryCode: string
113
+ first_name?: string
114
+ last_name?: string
115
+ }) {
116
+ const baseUrl = getBaseUrl()
117
+
118
+ const response = await fetch(`${baseUrl}/store/customers/otp/verify`, {
119
+ method: "POST",
120
+ headers: getHeaders(),
121
+ body: JSON.stringify({
122
+ token: input.token,
123
+ code: input.code,
124
+ first_name: input.first_name,
125
+ last_name: input.last_name,
126
+ }),
127
+ })
128
+
129
+ if (!response.ok) {
130
+ const err = await response.json().catch(() => ({}))
131
+ return {
132
+ success: false as const,
133
+ error: (err as { message?: string }).message || "Invalid verification code",
134
+ }
135
+ }
136
+
137
+ const data = (await response.json()) as {
138
+ token?: string | null
139
+ verified?: boolean
140
+ is_new_user?: boolean
141
+ customer?: { email?: string }
142
+ }
143
+
144
+ const jwt = data.token
145
+ if (!jwt) {
146
+ return {
147
+ success: false as const,
148
+ error: "Verification succeeded but login token was not returned. Try signing in with your password.",
149
+ }
150
+ }
151
+
152
+ await setAuthToken(jwt)
153
+
154
+ try {
155
+ await transferCart(jwt)
156
+ } catch {
157
+ // non-fatal
158
+ }
159
+
160
+ const customerCacheTag = await getCacheTag("customers")
161
+ revalidateTag(customerCacheTag)
162
+
163
+ const countryCode = input.countryCode || "in"
164
+ redirect(`/${countryCode}`)
165
+ }
166
+
167
+ export async function completePasswordResetAction(input: {
168
+ email: string
169
+ password: string
170
+ token: string
171
+ countryCode: string
172
+ }) {
173
+ const result = await completePasswordReset({
174
+ email: input.email,
175
+ password: input.password,
176
+ token: input.token,
177
+ })
178
+
179
+ if (!result.success) {
180
+ return result
181
+ }
182
+
183
+ redirect(`/${input.countryCode || "in"}/account`)
184
+ }
185
+
186
+ export async function verifyRegistrationOtp(input: {
187
+ otpToken: string
188
+ code: string
189
+ countryCode: string
190
+ }) {
191
+ const response = await fetch(`${getBaseUrl()}/store/customers/otp/verify`, {
192
+ method: "POST",
193
+ headers: getHeaders(),
194
+ body: JSON.stringify({
195
+ token: input.otpToken,
196
+ code: input.code,
197
+ }),
198
+ cache: "no-store",
199
+ })
200
+
201
+ const data = (await response.json().catch(() => ({}))) as {
202
+ message?: string
203
+ token?: string | null
204
+ email_verified?: boolean
205
+ phone_verified?: boolean
206
+ needs_login?: boolean
207
+ }
208
+
209
+ if (!response.ok) {
210
+ return {
211
+ success: false as const,
212
+ error: data.message || "Invalid verification code",
213
+ }
214
+ }
215
+
216
+ const loginToken = data.token
217
+ if (loginToken) {
218
+ await setAuthToken(loginToken)
219
+ try {
220
+ await transferCart(loginToken)
221
+ } catch {
222
+ // non-fatal
223
+ }
224
+ const customerCacheTag = await getCacheTag("customers")
225
+ revalidateTag(customerCacheTag)
226
+ redirect(`/${input.countryCode || "in"}`)
227
+ }
228
+
229
+ return {
230
+ success: true as const,
231
+ emailVerified: data.email_verified,
232
+ phoneVerified: data.phone_verified,
233
+ needsLogin: data.needs_login ?? true,
234
+ }
235
+ }
236
+
237
+ export async function sendRegistrationOtp(
238
+ customerId: string,
239
+ type: "email_verification" | "phone_verification"
240
+ ) {
241
+ const result = await sendCustomerOTP(customerId, type)
242
+ if (result.success) {
243
+ return result
244
+ }
245
+
246
+ const message = result.error || "Failed to send verification code"
247
+ if (message.includes("Channel configuration not found")) {
248
+ return {
249
+ success: false as const,
250
+ error:
251
+ "Verification email is not configured on the backend. Rebuild and restart the Medusa server with customer-registration OTP settings.",
252
+ }
253
+ }
254
+
255
+ return { success: false as const, error: message }
256
+ }
257
+
258
+ export async function initiateGoogleAuth(countryCode?: string) {
259
+ try {
260
+ const backendUrl = getBaseUrl()
261
+
262
+ if (!backendUrl) {
263
+ return { error: "Backend URL not configured" }
264
+ }
265
+
266
+ if (!process.env.GOOGLE_CLIENT_ID && !process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
267
+ return { error: "Google sign-in is not configured on the storefront" }
268
+ }
269
+
270
+ const callbackUrl = getGoogleOAuthCallbackUrl(countryCode)
271
+
272
+ const response = await fetch(`${backendUrl}/auth/customer/google`, {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({ callback_url: callbackUrl }),
276
+ cache: "no-store",
277
+ })
278
+
279
+ if (!response.ok) {
280
+ const err = (await response.json().catch(() => ({}))) as {
281
+ message?: string
282
+ type?: string
283
+ }
284
+ const message = err.message?.trim()
285
+ if (message?.includes("Unable to retrieve the auth provider")) {
286
+ return {
287
+ error:
288
+ "Google sign-in is not enabled on the backend. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in backend/.env and restart the Medusa server.",
289
+ }
290
+ }
291
+ return { error: message || "Google sign-in failed. Check backend Google OAuth configuration." }
292
+ }
293
+
294
+ const result = await response.json()
295
+ const redirectUrl =
296
+ typeof result === "string" ? result : (result as { location?: string })?.location
297
+
298
+ if (!redirectUrl) {
299
+ return { error: "No redirect URL received from Google OAuth" }
300
+ }
301
+
302
+ return { redirectUrl }
303
+ } catch (error: unknown) {
304
+ const message = error instanceof Error ? error.message : "Internal server error"
305
+ return { error: message }
306
+ }
307
+ }
@@ -0,0 +1,104 @@
1
+ "use client"
2
+
3
+ import OtpInput from "@pradip1995/commerce-auth/components/otp-input"
4
+ import type { OtpVerificationModalProps } from "./otp-verification-modal"
5
+
6
+ /**
7
+ * OTP modal styled like commerce-auth/chocomelon, compatible with segment-login-template props.
8
+ * Use in pages.config: `"otpComponent": "@pradip1995/segment-login-template/commerce-auth-otp-modal"`
9
+ */
10
+ export default function CommerceAuthOtpModal({
11
+ open,
12
+ title,
13
+ description,
14
+ otp,
15
+ onOtpChange,
16
+ error,
17
+ pending,
18
+ success = false,
19
+ successTitle = "Verified",
20
+ successMessage = "Verification successful!",
21
+ verifyLabel = "Verify",
22
+ onVerify,
23
+ onResend,
24
+ onClose,
25
+ preventClose = false,
26
+ extraContent,
27
+ }: OtpVerificationModalProps) {
28
+ if (!open) return null
29
+
30
+ function handleBackdropClick() {
31
+ if (preventClose || pending || success) return
32
+ onClose?.()
33
+ }
34
+
35
+ return (
36
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
37
+ <button
38
+ type="button"
39
+ className="absolute inset-0 bg-black/40"
40
+ aria-label="Close verification dialog"
41
+ onClick={handleBackdropClick}
42
+ />
43
+ <div className="relative w-full max-w-md rounded-2xl bg-page-bg shadow-xl p-6">
44
+ <div className="flex flex-col gap-6 py-4 px-2">
45
+ <div className="text-center">
46
+ <h2 className="text-2xl font-bold text-heading mb-2">
47
+ {success ? successTitle : title}
48
+ </h2>
49
+ {!success && (
50
+ <p className="text-sm text-gray-500 leading-relaxed max-w-[320px] mx-auto">
51
+ {description}
52
+ </p>
53
+ )}
54
+ </div>
55
+
56
+ {success ? (
57
+ <div className="text-center py-6">
58
+ <p className="text-brand-accent font-bold animate-pulse">{successMessage}</p>
59
+ </div>
60
+ ) : (
61
+ <>
62
+ {extraContent}
63
+
64
+ <OtpInput value={otp} onChange={onOtpChange} autoFocus />
65
+
66
+ {error && (
67
+ <p className="text-red-500 text-xs text-center" role="alert">
68
+ {error}
69
+ </p>
70
+ )}
71
+
72
+ <div className="space-y-3">
73
+ <button
74
+ type="button"
75
+ onClick={onVerify}
76
+ disabled={pending || otp.length < 6}
77
+ className="w-full py-3 bg-brand-accent text-inverse font-bold hover:opacity-90 rounded-[30px] transition-colors disabled:opacity-60"
78
+ >
79
+ {pending ? "Verifying…" : verifyLabel}
80
+ </button>
81
+
82
+ <button
83
+ type="button"
84
+ onClick={onResend}
85
+ disabled={pending}
86
+ className="text-xs text-gray-500 hover:text-brand-accent w-full text-center underline font-medium disabled:opacity-60"
87
+ >
88
+ Resend code
89
+ </button>
90
+ </div>
91
+
92
+ {preventClose && (
93
+ <p className="text-xs text-muted text-center">
94
+ Verification is required to complete{" "}
95
+ {verifyLabel.toLowerCase().includes("sign in") ? "sign in" : "registration"}.
96
+ </p>
97
+ )}
98
+ </>
99
+ )}
100
+ </div>
101
+ </div>
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,89 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { checkEmailRegistered, requestPasswordReset } from "./auth-server"
5
+
6
+ export default function ForgotPasswordForm({ onBack }: { onBack: () => void }) {
7
+ const [email, setEmail] = useState("")
8
+ const [message, setMessage] = useState<string | null>(null)
9
+ const [error, setError] = useState<string | null>(null)
10
+ const [pending, setPending] = useState(false)
11
+ const [success, setSuccess] = useState(false)
12
+
13
+ async function handleSubmit(e: React.FormEvent) {
14
+ e.preventDefault()
15
+ if (!email.trim()) return
16
+
17
+ setPending(true)
18
+ setMessage(null)
19
+ setError(null)
20
+
21
+ try {
22
+ const check = await checkEmailRegistered(email.trim())
23
+ if (!check.exists) {
24
+ setError("This email is not registered. Please create a new account instead.")
25
+ return
26
+ }
27
+
28
+ const result = await requestPasswordReset(email.trim())
29
+ if (!result.success) {
30
+ setError(result.error || "Failed to send reset link")
31
+ return
32
+ }
33
+
34
+ setSuccess(true)
35
+ setMessage(result.message || "If an account exists, you will receive a reset link shortly.")
36
+ } catch (err) {
37
+ setError(err instanceof Error ? err.message : "Something went wrong")
38
+ } finally {
39
+ setPending(false)
40
+ }
41
+ }
42
+
43
+ if (success) {
44
+ return (
45
+ <div className="space-y-4 text-center py-4">
46
+ <div className="w-14 h-14 mx-auto rounded-full bg-green-100 flex items-center justify-center text-2xl">
47
+
48
+ </div>
49
+ <h2 className="section-heading text-lg">Check your email</h2>
50
+ <p className="text-sm text-muted">{message}</p>
51
+ <button type="button" onClick={onBack} className="btn-outline w-full">
52
+ Back to sign in
53
+ </button>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <form onSubmit={handleSubmit} className="space-y-4">
60
+ <p className="text-sm text-muted">
61
+ Enter your email and we&apos;ll send a link to reset your password.
62
+ </p>
63
+ <label className="block">
64
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
65
+ Email
66
+ </span>
67
+ <input
68
+ name="email"
69
+ type="email"
70
+ value={email}
71
+ onChange={(e) => setEmail(e.target.value)}
72
+ required
73
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
74
+ />
75
+ </label>
76
+ {error && <p className="text-sm text-brand-sale">{error}</p>}
77
+ <button type="submit" disabled={pending} className="btn-primary w-full disabled:opacity-60">
78
+ {pending ? "Sending…" : "Send reset link"}
79
+ </button>
80
+ <button
81
+ type="button"
82
+ onClick={onBack}
83
+ className="text-sm text-muted hover:text-brand-accent w-full text-center"
84
+ >
85
+ Back to sign in
86
+ </button>
87
+ </form>
88
+ )
89
+ }
@@ -0,0 +1,86 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import {
5
+ clearMedusaAuthCookies,
6
+ GOOGLE_LOGIN_COUNTRY_CODE_KEY,
7
+ } from "@pradip1995/commerce-auth/util/google-auth-client"
8
+ import { initiateGoogleAuth } from "./auth-server"
9
+
10
+ const BUTTON_CLASS =
11
+ "btn-outline w-full flex items-center justify-center gap-2.5 py-3 px-4"
12
+
13
+ export default function GoogleAuthSection({ countryCode }: { countryCode: string }) {
14
+ const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID?.trim()
15
+ const [loading, setLoading] = useState(false)
16
+ const [error, setError] = useState<string | null>(null)
17
+
18
+ if (!clientId) return null
19
+
20
+ const handleClick = async () => {
21
+ setError(null)
22
+ setLoading(true)
23
+
24
+ try {
25
+ localStorage.setItem(GOOGLE_LOGIN_COUNTRY_CODE_KEY, countryCode)
26
+ clearMedusaAuthCookies()
27
+
28
+ const result = await initiateGoogleAuth(countryCode)
29
+
30
+ if (result.error) {
31
+ setError(result.error)
32
+ setLoading(false)
33
+ return
34
+ }
35
+
36
+ if (result.redirectUrl) {
37
+ window.location.href = result.redirectUrl
38
+ return
39
+ }
40
+
41
+ setLoading(false)
42
+ } catch {
43
+ setError("Google sign-in failed. Please try again.")
44
+ setLoading(false)
45
+ }
46
+ }
47
+
48
+ return (
49
+ <div className="mb-6">
50
+ {error && (
51
+ <p className="mb-3 text-sm text-red-600" role="alert">
52
+ {error}
53
+ </p>
54
+ )}
55
+ <button
56
+ type="button"
57
+ onClick={handleClick}
58
+ disabled={loading}
59
+ className={BUTTON_CLASS}
60
+ style={{ borderRadius: "30px" }}
61
+ >
62
+ <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
63
+ <path
64
+ fill="#4285F4"
65
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
66
+ />
67
+ <path
68
+ fill="#34A853"
69
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
70
+ />
71
+ <path
72
+ fill="#FBBC05"
73
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
74
+ />
75
+ <path
76
+ fill="#EA4335"
77
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
78
+ />
79
+ </svg>
80
+ <span className="text-sm text-gray-700 font-bold">
81
+ {loading ? "Connecting..." : "Continue with Google"}
82
+ </span>
83
+ </button>
84
+ </div>
85
+ )
86
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export { default } from "./segment"
2
2
  export { default as manifest } from "./manifest"
3
+ export { default as ResetPasswordPage } from "./reset-password-page"
4
+ export { default as OtpVerificationModal } from "./otp-verification-modal"
5
+ export { default as CommerceAuthOtpModal } from "./commerce-auth-otp-modal"
6
+ export type { OtpVerificationModalProps } from "./otp-verification-modal"
7
+ export { resolveOtpComponent, type OtpVerificationComponent } from "./otp-component"