@pradip1995/segment-login-template 0.3.0 → 0.4.1

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,229 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import {
5
+ createPaymentDetail,
6
+ deletePaymentDetail,
7
+ makeDefaultPaymentDetail,
8
+ } from "@pradip1995/commerce-core/data/payment-details"
9
+
10
+ type PaymentDetail = {
11
+ id: string
12
+ type: "upi" | "bank" | "card"
13
+ detail_json?: Record<string, string>
14
+ is_default?: boolean
15
+ }
16
+
17
+ export default function AccountPaymentMethods({
18
+ paymentDetails,
19
+ }: {
20
+ paymentDetails: PaymentDetail[]
21
+ }) {
22
+ const [showForm, setShowForm] = useState(false)
23
+ const [type, setType] = useState<"upi" | "bank" | "card">("upi")
24
+ const [pending, setPending] = useState(false)
25
+ const [error, setError] = useState<string | null>(null)
26
+ const [form, setForm] = useState({
27
+ upi_id: "",
28
+ account_holder: "",
29
+ account_number: "",
30
+ ifsc: "",
31
+ card_last4: "",
32
+ })
33
+
34
+ async function handleAdd(e: React.FormEvent) {
35
+ e.preventDefault()
36
+ setPending(true)
37
+ setError(null)
38
+ try {
39
+ let detail_json: Record<string, string> = {}
40
+ if (type === "upi") {
41
+ detail_json = { upi_id: form.upi_id.trim() }
42
+ } else if (type === "bank") {
43
+ detail_json = {
44
+ account_holder: form.account_holder.trim(),
45
+ account_number: form.account_number.trim(),
46
+ ifsc: form.ifsc.trim(),
47
+ }
48
+ } else {
49
+ detail_json = { card_last4: form.card_last4.trim() }
50
+ }
51
+ await createPaymentDetail(type, detail_json)
52
+ window.location.reload()
53
+ } catch (err) {
54
+ setError(err instanceof Error ? err.message : "Failed to add payment method")
55
+ } finally {
56
+ setPending(false)
57
+ }
58
+ }
59
+
60
+ async function handleDefault(id: string) {
61
+ setPending(true)
62
+ try {
63
+ await makeDefaultPaymentDetail(id)
64
+ window.location.reload()
65
+ } finally {
66
+ setPending(false)
67
+ }
68
+ }
69
+
70
+ async function handleDelete(id: string) {
71
+ if (!confirm("Remove this payment method?")) return
72
+ setPending(true)
73
+ try {
74
+ await deletePaymentDetail(id)
75
+ window.location.reload()
76
+ } finally {
77
+ setPending(false)
78
+ }
79
+ }
80
+
81
+ function labelFor(detail: PaymentDetail) {
82
+ const json = detail.detail_json ?? {}
83
+ if (detail.type === "upi") return `UPI · ${json.upi_id || "—"}`
84
+ if (detail.type === "bank")
85
+ return `Bank · ${json.account_holder || "—"} (${json.account_number?.slice(-4) || "****"})`
86
+ return `Card · **** ${json.card_last4 || "****"}`
87
+ }
88
+
89
+ return (
90
+ <div className="space-y-6">
91
+ <div className="flex flex-wrap items-end justify-between gap-4">
92
+ <div>
93
+ <h2 className="section-heading text-xl mb-1">Payment methods</h2>
94
+ <p className="text-sm text-muted">Refund destinations for returns and exchanges.</p>
95
+ </div>
96
+ <button type="button" onClick={() => setShowForm((v) => !v)} className="btn-primary text-sm">
97
+ {showForm ? "Cancel" : "Add method"}
98
+ </button>
99
+ </div>
100
+
101
+ {showForm && (
102
+ <form onSubmit={handleAdd} className="card-surface rounded-2xl p-6 space-y-4">
103
+ <div className="flex flex-wrap gap-2">
104
+ {(["upi", "bank", "card"] as const).map((t) => (
105
+ <button
106
+ key={t}
107
+ type="button"
108
+ onClick={() => setType(t)}
109
+ className={`px-3 py-1.5 text-xs font-medium rounded-full border ${
110
+ type === t
111
+ ? "border-brand-accent text-brand-accent bg-brand-accent/5"
112
+ : "border-cart-border text-muted"
113
+ }`}
114
+ >
115
+ {t.toUpperCase()}
116
+ </button>
117
+ ))}
118
+ </div>
119
+
120
+ {type === "upi" && (
121
+ <Field
122
+ label="UPI ID"
123
+ value={form.upi_id}
124
+ onChange={(v) => setForm((f) => ({ ...f, upi_id: v }))}
125
+ />
126
+ )}
127
+ {type === "bank" && (
128
+ <>
129
+ <Field
130
+ label="Account holder"
131
+ value={form.account_holder}
132
+ onChange={(v) => setForm((f) => ({ ...f, account_holder: v }))}
133
+ />
134
+ <Field
135
+ label="Account number"
136
+ value={form.account_number}
137
+ onChange={(v) => setForm((f) => ({ ...f, account_number: v }))}
138
+ />
139
+ <Field
140
+ label="IFSC"
141
+ value={form.ifsc}
142
+ onChange={(v) => setForm((f) => ({ ...f, ifsc: v }))}
143
+ />
144
+ </>
145
+ )}
146
+ {type === "card" && (
147
+ <Field
148
+ label="Last 4 digits"
149
+ value={form.card_last4}
150
+ onChange={(v) => setForm((f) => ({ ...f, card_last4: v }))}
151
+ />
152
+ )}
153
+
154
+ {error && <p className="text-sm text-brand-sale">{error}</p>}
155
+
156
+ <button type="submit" disabled={pending} className="btn-primary text-sm disabled:opacity-60">
157
+ {pending ? "Saving…" : "Save payment method"}
158
+ </button>
159
+ </form>
160
+ )}
161
+
162
+ {paymentDetails.length === 0 ? (
163
+ <div className="card-surface rounded-2xl p-8 text-center text-sm text-muted">
164
+ No refund payment methods saved.
165
+ </div>
166
+ ) : (
167
+ <ul className="space-y-3">
168
+ {paymentDetails.map((detail) => (
169
+ <li
170
+ key={detail.id}
171
+ className="card-surface rounded-2xl p-5 flex flex-wrap justify-between gap-4 items-center"
172
+ >
173
+ <div>
174
+ <p className="font-medium text-heading text-sm">{labelFor(detail)}</p>
175
+ {detail.is_default && (
176
+ <p className="text-xs text-brand-accent font-medium mt-1">Default</p>
177
+ )}
178
+ </div>
179
+ <div className="flex gap-3 text-xs">
180
+ {!detail.is_default && (
181
+ <button
182
+ type="button"
183
+ onClick={() => handleDefault(detail.id)}
184
+ disabled={pending}
185
+ className="text-brand-accent hover:underline disabled:opacity-60"
186
+ >
187
+ Make default
188
+ </button>
189
+ )}
190
+ <button
191
+ type="button"
192
+ onClick={() => handleDelete(detail.id)}
193
+ disabled={pending}
194
+ className="text-brand-sale hover:underline disabled:opacity-60"
195
+ >
196
+ Remove
197
+ </button>
198
+ </div>
199
+ </li>
200
+ ))}
201
+ </ul>
202
+ )}
203
+ </div>
204
+ )
205
+ }
206
+
207
+ function Field({
208
+ label,
209
+ value,
210
+ onChange,
211
+ }: {
212
+ label: string
213
+ value: string
214
+ onChange: (value: string) => void
215
+ }) {
216
+ return (
217
+ <label className="block">
218
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
219
+ {label}
220
+ </span>
221
+ <input
222
+ value={value}
223
+ onChange={(e) => onChange(e.target.value)}
224
+ required
225
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
226
+ />
227
+ </label>
228
+ )
229
+ }
@@ -0,0 +1,198 @@
1
+ "use client"
2
+
3
+ import { useActionState, useState } from "react"
4
+ import type { HttpTypes } from "@medusajs/types"
5
+ import { updateCustomer } from "@pradip1995/commerce-core/data/customer"
6
+ import {
7
+ sendRegistrationOtp,
8
+ verifyRegistrationOtp,
9
+ } from "./auth-server"
10
+ import type { OtpVerificationComponent } from "./otp-component"
11
+ import OtpVerificationModal from "./otp-verification-modal"
12
+
13
+ export default function AccountProfile({
14
+ customer,
15
+ countryCode,
16
+ OtpComponent = OtpVerificationModal,
17
+ }: {
18
+ customer: HttpTypes.StoreCustomer
19
+ countryCode: string
20
+ OtpComponent?: OtpVerificationComponent
21
+ }) {
22
+ const [message, setMessage] = useState<string | null>(null)
23
+ const [otpOpen, setOtpOpen] = useState(false)
24
+ const [otp, setOtp] = useState("")
25
+ const [otpToken, setOtpToken] = useState<string | null>(null)
26
+ const [otpType, setOtpType] = useState<"email_verification" | "phone_verification">(
27
+ "email_verification"
28
+ )
29
+ const [otpError, setOtpError] = useState<string | null>(null)
30
+ const [otpPending, setOtpPending] = useState(false)
31
+
32
+ async function saveProfile(_prev: unknown, formData: FormData) {
33
+ try {
34
+ const fullName = String(formData.get("full_name") || "").trim()
35
+ const [first_name, ...rest] = fullName.split(/\s+/)
36
+ await updateCustomer({
37
+ first_name: first_name || "",
38
+ last_name: rest.join(" ") || ".",
39
+ phone: String(formData.get("phone") || "").replace(/[^\d+]/g, "") || undefined,
40
+ })
41
+ return { ok: true as const, error: null as string | null }
42
+ } catch (err) {
43
+ return {
44
+ ok: false as const,
45
+ error: err instanceof Error ? err.message : "Failed to update profile",
46
+ }
47
+ }
48
+ }
49
+
50
+ const [state, formAction, pending] = useActionState(saveProfile, { ok: false, error: null })
51
+
52
+ async function sendVerifyOtp(type: "email_verification" | "phone_verification") {
53
+ setOtpType(type)
54
+ setOtpPending(true)
55
+ setOtpError(null)
56
+ try {
57
+ const res = await sendRegistrationOtp(customer.id, type)
58
+ if (!res.success) throw new Error(res.error || "Failed to send code")
59
+ setOtpToken(res.token ?? null)
60
+ setOtp("")
61
+ setOtpOpen(true)
62
+ } catch (err) {
63
+ setOtpError(err instanceof Error ? err.message : "Failed to send code")
64
+ } finally {
65
+ setOtpPending(false)
66
+ }
67
+ }
68
+
69
+ async function verifyOtp() {
70
+ if (otp.length < 6) return
71
+ setOtpPending(true)
72
+ setOtpError(null)
73
+ try {
74
+ const res = await verifyRegistrationOtp({
75
+ otpToken: otpToken || "",
76
+ code: otp,
77
+ countryCode,
78
+ })
79
+ if (!res.success) throw new Error(res.error || "Invalid code")
80
+ setOtpOpen(false)
81
+ setMessage("Verification successful.")
82
+ window.location.reload()
83
+ } catch (err) {
84
+ setOtpError(err instanceof Error ? err.message : "Verification failed")
85
+ } finally {
86
+ setOtpPending(false)
87
+ }
88
+ }
89
+
90
+ const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(" ")
91
+ const emailVerified = (customer as { email_verified?: boolean }).email_verified === true
92
+ const phoneVerified = (customer as { phone_verified?: boolean }).phone_verified === true
93
+
94
+ return (
95
+ <div className="space-y-8">
96
+ <div>
97
+ <h2 className="section-heading text-xl mb-1">Account details</h2>
98
+ <p className="text-sm text-muted">Update your name and contact information.</p>
99
+ </div>
100
+
101
+ <form action={formAction} className="card-surface rounded-2xl p-6 space-y-4">
102
+ <Field label="Full name" name="full_name" defaultValue={fullName} />
103
+ <div>
104
+ <label className="block text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5">
105
+ Email
106
+ </label>
107
+ <div className="flex flex-wrap items-center gap-3">
108
+ <input
109
+ value={customer.email}
110
+ readOnly
111
+ className="flex-1 min-w-[200px] border border-cart-border rounded px-3 py-2.5 text-sm bg-surface-muted text-muted"
112
+ />
113
+ {!emailVerified && (
114
+ <button
115
+ type="button"
116
+ onClick={() => sendVerifyOtp("email_verification")}
117
+ disabled={otpPending}
118
+ className="btn-outline text-xs shrink-0"
119
+ >
120
+ Verify email
121
+ </button>
122
+ )}
123
+ {emailVerified && (
124
+ <span className="text-xs text-green-600 font-medium">Verified</span>
125
+ )}
126
+ </div>
127
+ </div>
128
+ <div>
129
+ <Field label="Phone" name="phone" type="tel" defaultValue={customer.phone || ""} />
130
+ <div className="mt-2 flex items-center gap-3">
131
+ {!phoneVerified && customer.phone && (
132
+ <button
133
+ type="button"
134
+ onClick={() => sendVerifyOtp("phone_verification")}
135
+ disabled={otpPending}
136
+ className="btn-outline text-xs"
137
+ >
138
+ Verify phone
139
+ </button>
140
+ )}
141
+ {phoneVerified && customer.phone && (
142
+ <span className="text-xs text-green-600 font-medium">Verified</span>
143
+ )}
144
+ </div>
145
+ </div>
146
+
147
+ {(state.error || message) && (
148
+ <p className={`text-sm ${state.ok || message ? "text-green-600" : "text-brand-sale"}`}>
149
+ {message || state.error}
150
+ </p>
151
+ )}
152
+
153
+ <button type="submit" disabled={pending} className="btn-primary disabled:opacity-60">
154
+ {pending ? "Saving…" : "Save changes"}
155
+ </button>
156
+ </form>
157
+
158
+ <OtpComponent
159
+ open={otpOpen}
160
+ title={otpType === "email_verification" ? "Verify your email" : "Verify your phone"}
161
+ description={`Enter the code sent to your ${otpType === "email_verification" ? "email" : "phone"}.`}
162
+ otp={otp}
163
+ onOtpChange={setOtp}
164
+ error={otpError}
165
+ pending={otpPending}
166
+ onVerify={verifyOtp}
167
+ onResend={() => sendVerifyOtp(otpType)}
168
+ onClose={() => setOtpOpen(false)}
169
+ />
170
+ </div>
171
+ )
172
+ }
173
+
174
+ function Field({
175
+ label,
176
+ name,
177
+ type = "text",
178
+ defaultValue,
179
+ }: {
180
+ label: string
181
+ name: string
182
+ type?: string
183
+ defaultValue?: string
184
+ }) {
185
+ return (
186
+ <label className="block">
187
+ <span className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted mb-1.5 block">
188
+ {label}
189
+ </span>
190
+ <input
191
+ name={name}
192
+ type={type}
193
+ defaultValue={defaultValue}
194
+ className="w-full border border-cart-border rounded px-3 py-2.5 text-sm bg-surface focus:outline-none focus:border-brand-accent"
195
+ />
196
+ </label>
197
+ )
198
+ }
@@ -0,0 +1,20 @@
1
+ import type { HttpTypes } from "@medusajs/types"
2
+
3
+ export function getProfileCompletion(customer: HttpTypes.StoreCustomer): number {
4
+ let score = 0
5
+ if (customer.first_name?.trim()) score += 25
6
+ if (customer.last_name?.trim()) score += 25
7
+ if (customer.email?.trim()) score += 25
8
+ if (customer.phone?.trim()) score += 25
9
+ return score
10
+ }
11
+
12
+ export function customerDisplayName(customer: HttpTypes.StoreCustomer): string {
13
+ return [customer.first_name, customer.last_name].filter(Boolean).join(" ") || customer.email || "Account"
14
+ }
15
+
16
+ export function accountSectionPath(path?: string): string {
17
+ if (!path || path === "login") return "overview"
18
+ const base = path.split("/")[0]
19
+ return base || "overview"
20
+ }
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  checkEmailRegistered,
5
5
  completePasswordReset,
6
- registerCustomer,
6
+ registerCustomer as registerCustomerBase,
7
7
  requestPasswordReset,
8
8
  sendCustomerOTP,
9
9
  verifyCustomerOTP,
@@ -11,11 +11,11 @@ import {
11
11
  import { transferCart } from "@pradip1995/commerce-core/data/customer"
12
12
  import { getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
13
13
  import { sendOTP, verifyOTP } from "@pradip1995/commerce-core/data/guest"
14
+ import { getGoogleOAuthCallbackUrl } from "@pradip1995/commerce-core/util/google-oauth"
14
15
  import { revalidateTag } from "next/cache"
15
16
  import { redirect } from "next/navigation"
16
17
 
17
18
  export {
18
- registerCustomer,
19
19
  sendCustomerOTP,
20
20
  verifyCustomerOTP,
21
21
  requestPasswordReset,
@@ -25,6 +25,38 @@ export {
25
25
  verifyOTP,
26
26
  }
27
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
+
28
60
  function getHeaders() {
29
61
  return {
30
62
  "Content-Type": "application/json",
@@ -150,3 +182,126 @@ export async function completePasswordResetAction(input: {
150
182
 
151
183
  redirect(`/${input.countryCode || "in"}/account`)
152
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
+ }