@pradip1995/segment-jewelry-checkout-form 0.1.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.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@pradip1995/segment-jewelry-checkout-form",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "./src/jewelry-checkout-form.css"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./manifest": "./src/manifest.ts",
17
+ "./payment-block": "./src/payment-block.tsx"
18
+ },
19
+ "peerDependencies": {
20
+ "@pradip1995/commerce-core": "^4.0.0",
21
+ "@pradip1995/plugin-sdk": "^0.2.0",
22
+ "next": ">=15",
23
+ "react": ">=19",
24
+ "react-dom": ">=19"
25
+ },
26
+ "dependencies": {
27
+ "@medusajs/types": "^2.8.0",
28
+ "@pradip1995/segment-analytics": "^0.2.2",
29
+ "@pradip1995/segment-primitives": "^0.4.2",
30
+ "@pradip1995/segment-tokens": "^0.3.7"
31
+ },
32
+ "devDependencies": {
33
+ "@pradip1995/plugin-sdk": "^0.2.0",
34
+ "@types/react": "^19",
35
+ "react": "19.0.3",
36
+ "typescript": "^5.7.2"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "tsc --noEmit"
41
+ }
42
+ }
@@ -0,0 +1,495 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import { SplitPhoneInput } from "@pradip1995/commerce-core/components/split-phone-input"
5
+ import { updateAddressSilently } from "@pradip1995/commerce-core/client/actions/cart"
6
+ import { useShiprocketDeliveryEstimate } from "@pradip1995/commerce-core/hooks/use-shiprocket-delivery-estimate"
7
+ import {
8
+ CHECKOUT_SYNC_EVENTS,
9
+ dispatchCheckoutSyncEvent,
10
+ } from "@pradip1995/commerce-core/util/checkout-sync-events"
11
+ import { isValidEmail, isValidIndianPhone, toFormPhoneValue } from "@pradip1995/commerce-core/util/phone"
12
+ import type { HttpTypes } from "@medusajs/types"
13
+ import { useRouter } from "next/navigation"
14
+ import DeliveryInfo from "./delivery-info"
15
+ import FloatingInput from "./floating-input"
16
+ import { fetchPincodeLocation } from "./pincode-location"
17
+
18
+ export type CheckoutFormValues = {
19
+ fullName: string
20
+ firstName: string
21
+ lastName: string
22
+ email: string
23
+ phone: string
24
+ address1: string
25
+ postalCode: string
26
+ city: string
27
+ province: string
28
+ countryCode: string
29
+ }
30
+
31
+ const NEW_ADDRESS_ID = "__new__"
32
+
33
+ function splitFullName(fullName: string): { firstName: string; lastName: string } {
34
+ const trimmed = fullName.trim()
35
+ if (!trimmed) return { firstName: "", lastName: "." }
36
+ const parts = trimmed.split(/\s+/)
37
+ if (parts.length === 1) return { firstName: parts[0], lastName: "." }
38
+ return {
39
+ firstName: parts[0],
40
+ lastName: parts.slice(1).join(" ") || ".",
41
+ }
42
+ }
43
+
44
+ function formatAddressName(address: HttpTypes.StoreCustomerAddress): string {
45
+ return [address.first_name, address.last_name === "." ? "" : address.last_name]
46
+ .filter(Boolean)
47
+ .join(" ")
48
+ .trim()
49
+ }
50
+
51
+ function addressToFormValues(
52
+ address: HttpTypes.StoreCustomerAddress,
53
+ fallback?: Partial<CheckoutFormValues>
54
+ ): CheckoutFormValues {
55
+ const fullName = formatAddressName(address)
56
+ return {
57
+ fullName,
58
+ firstName: address.first_name || "",
59
+ lastName: address.last_name || ".",
60
+ email: fallback?.email || "",
61
+ phone: toFormPhoneValue(address.phone || fallback?.phone),
62
+ address1: address.address_1 || "",
63
+ postalCode: address.postal_code || "",
64
+ city: address.city || "",
65
+ province: address.province || "",
66
+ countryCode: address.country_code || fallback?.countryCode || "in",
67
+ }
68
+ }
69
+
70
+ function pickPreferredAddress(
71
+ customer?: HttpTypes.StoreCustomer | null
72
+ ): HttpTypes.StoreCustomerAddress | undefined {
73
+ const addresses = customer?.addresses ?? []
74
+ return (
75
+ addresses.find((a) => a.is_default_shipping) ||
76
+ addresses.find(
77
+ (a) =>
78
+ a.is_default_billing ||
79
+ a.metadata?.is_default === true ||
80
+ a.metadata?.is_default === "true"
81
+ ) ||
82
+ addresses[0]
83
+ )
84
+ }
85
+
86
+ function findMatchingSavedAddress(
87
+ cart: HttpTypes.StoreCart | undefined,
88
+ addresses: HttpTypes.StoreCustomerAddress[]
89
+ ): HttpTypes.StoreCustomerAddress | undefined {
90
+ const shipping = cart?.shipping_address
91
+ if (!shipping?.address_1 || !addresses.length) return undefined
92
+
93
+ return addresses.find(
94
+ (address) =>
95
+ (address.address_1 || "").trim().toLowerCase() ===
96
+ (shipping.address_1 || "").trim().toLowerCase() &&
97
+ (address.postal_code || "") === (shipping.postal_code || "") &&
98
+ (address.city || "").trim().toLowerCase() === (shipping.city || "").trim().toLowerCase()
99
+ )
100
+ }
101
+
102
+ function buildInitialValues(
103
+ cart?: HttpTypes.StoreCart,
104
+ customer?: HttpTypes.StoreCustomer | null
105
+ ): CheckoutFormValues {
106
+ const shipping = cart?.shipping_address
107
+ const preferred = !shipping?.address_1 ? pickPreferredAddress(customer) : undefined
108
+ const source = shipping?.address_1 ? shipping : preferred
109
+
110
+ const fullName = source?.first_name
111
+ ? `${source.first_name} ${source.last_name === "." ? "" : source.last_name || ""}`.trim()
112
+ : customer?.first_name
113
+ ? `${customer.first_name} ${customer.last_name || ""}`.trim()
114
+ : ""
115
+
116
+ return {
117
+ fullName,
118
+ firstName: source?.first_name || customer?.first_name || "",
119
+ lastName: source?.last_name || customer?.last_name || "",
120
+ email: cart?.email || customer?.email || "",
121
+ phone: toFormPhoneValue(source?.phone || customer?.phone),
122
+ address1: source?.address_1 || "",
123
+ postalCode: source?.postal_code || "",
124
+ city: source?.city || "",
125
+ province: source?.province || "",
126
+ countryCode:
127
+ source?.country_code || cart?.region?.countries?.[0]?.iso_2 || "in",
128
+ }
129
+ }
130
+
131
+ function resolveInitialSelectedId(
132
+ cart?: HttpTypes.StoreCart,
133
+ customer?: HttpTypes.StoreCustomer | null
134
+ ): string {
135
+ const addresses = customer?.addresses ?? []
136
+ if (!addresses.length) return NEW_ADDRESS_ID
137
+
138
+ const matched = findMatchingSavedAddress(cart, addresses)
139
+ if (matched?.id) return matched.id
140
+
141
+ if (!cart?.shipping_address?.address_1) {
142
+ const preferred = pickPreferredAddress(customer)
143
+ if (preferred?.id) return preferred.id
144
+ }
145
+
146
+ return NEW_ADDRESS_ID
147
+ }
148
+
149
+ type AddressFieldsProps = {
150
+ cart: HttpTypes.StoreCart
151
+ customer?: HttpTypes.StoreCustomer | null
152
+ onValuesChange?: (values: CheckoutFormValues) => void
153
+ }
154
+
155
+ export default function AddressFields({ cart, customer, onValuesChange }: AddressFieldsProps) {
156
+ const router = useRouter()
157
+ const savedAddresses = customer?.addresses ?? []
158
+ const [values, setValues] = useState(() => buildInitialValues(cart, customer))
159
+ const [selectedAddressId, setSelectedAddressId] = useState(() =>
160
+ resolveInitialSelectedId(cart, customer)
161
+ )
162
+ const [phoneError, setPhoneError] = useState(false)
163
+ const [isSyncingPincode, setIsSyncingPincode] = useState(false)
164
+ const [isSelectingAddress, setIsSelectingAddress] = useState(false)
165
+ const lastPincodeSyncRef = useRef("")
166
+ const didPrefillDefaultRef = useRef(false)
167
+
168
+ const firstVariantId = cart.items?.[0]?.variant_id ?? null
169
+ const { deliveryEstimate, formatDeliveryDate, fallbackDeliveryDate } =
170
+ useShiprocketDeliveryEstimate({
171
+ postalCode: values.postalCode,
172
+ variantId: firstVariantId,
173
+ cartMetadata: cart.metadata,
174
+ })
175
+
176
+ useEffect(() => {
177
+ onValuesChange?.(values)
178
+ }, [values, onValuesChange])
179
+
180
+ useEffect(() => {
181
+ if (didPrefillDefaultRef.current) return
182
+ if (cart.shipping_address?.address_1) return
183
+ if (selectedAddressId === NEW_ADDRESS_ID) return
184
+
185
+ const selected = savedAddresses.find((address) => address.id === selectedAddressId)
186
+ if (!selected) return
187
+
188
+ didPrefillDefaultRef.current = true
189
+ void applySavedAddress(selected, { refresh: true })
190
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for default address prefill
191
+ }, [])
192
+
193
+ const updateField = (key: keyof CheckoutFormValues, next: string) => {
194
+ setSelectedAddressId(NEW_ADDRESS_ID)
195
+ setValues((prev) => {
196
+ const updated = { ...prev, [key]: next }
197
+ if (key === "fullName") {
198
+ const { firstName, lastName } = splitFullName(next)
199
+ updated.firstName = firstName
200
+ updated.lastName = lastName
201
+ }
202
+ return updated
203
+ })
204
+ }
205
+
206
+ const syncAddressToCart = async (
207
+ nextValues: CheckoutFormValues,
208
+ options?: { assignShipping?: boolean }
209
+ ) => {
210
+ const result = await updateAddressSilently({
211
+ email: nextValues.email || undefined,
212
+ shipping_address: {
213
+ first_name: nextValues.firstName,
214
+ last_name: nextValues.lastName,
215
+ address_1: nextValues.address1,
216
+ postal_code: nextValues.postalCode,
217
+ city: nextValues.city,
218
+ province: nextValues.province,
219
+ country_code: nextValues.countryCode || "in",
220
+ phone: nextValues.phone,
221
+ },
222
+ same_as_billing: true,
223
+ assignShipping: options?.assignShipping !== false,
224
+ })
225
+
226
+ if (result?.success) {
227
+ lastPincodeSyncRef.current = `${nextValues.postalCode}|${nextValues.city}|${nextValues.province}`
228
+ dispatchCheckoutSyncEvent(CHECKOUT_SYNC_EVENTS.ADDRESS_UPDATED_SILENT, {
229
+ syncType: "shipping",
230
+ })
231
+ }
232
+
233
+ return result
234
+ }
235
+
236
+ const applySavedAddress = async (
237
+ address: HttpTypes.StoreCustomerAddress,
238
+ options?: { refresh?: boolean }
239
+ ) => {
240
+ const nextValues = addressToFormValues(address, {
241
+ email: values.email || cart.email || customer?.email || "",
242
+ phone: values.phone || customer?.phone,
243
+ countryCode: values.countryCode,
244
+ })
245
+
246
+ setSelectedAddressId(address.id!)
247
+ setValues(nextValues)
248
+ setIsSelectingAddress(true)
249
+
250
+ try {
251
+ const result = await syncAddressToCart(nextValues)
252
+ if (result?.success && options?.refresh !== false) {
253
+ router.refresh()
254
+ }
255
+ } finally {
256
+ setIsSelectingAddress(false)
257
+ }
258
+ }
259
+
260
+ const syncPincodeToCart = async (postalCode: string, city: string, province: string) => {
261
+ const syncKey = `${postalCode}|${city}|${province}`
262
+ if (lastPincodeSyncRef.current === syncKey) return
263
+
264
+ setIsSyncingPincode(true)
265
+ try {
266
+ const result = await updateAddressSilently({
267
+ shipping_address: {
268
+ postal_code: postalCode,
269
+ city,
270
+ province,
271
+ country_code: values.countryCode || "in",
272
+ },
273
+ same_as_billing: true,
274
+ assignShipping: true,
275
+ })
276
+
277
+ if (result?.success) {
278
+ lastPincodeSyncRef.current = syncKey
279
+ dispatchCheckoutSyncEvent(CHECKOUT_SYNC_EVENTS.ADDRESS_UPDATED_SILENT, {
280
+ syncType: "shipping",
281
+ })
282
+ router.refresh()
283
+ }
284
+ } finally {
285
+ setIsSyncingPincode(false)
286
+ }
287
+ }
288
+
289
+ const handlePostalCodeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
290
+ const sanitized = e.target.value.replace(/\D/g, "").slice(0, 6)
291
+ updateField("postalCode", sanitized)
292
+
293
+ if (sanitized.length !== 6) {
294
+ lastPincodeSyncRef.current = ""
295
+ return
296
+ }
297
+
298
+ const location = await fetchPincodeLocation(sanitized)
299
+ const city = location?.city || values.city
300
+ const province = location?.province || values.province
301
+
302
+ setValues((prev) => ({
303
+ ...prev,
304
+ postalCode: sanitized,
305
+ city: location?.city || prev.city,
306
+ province: location?.province || prev.province,
307
+ }))
308
+
309
+ await syncPincodeToCart(sanitized, city, province)
310
+ }
311
+
312
+ const handleSelectNewAddress = () => {
313
+ setSelectedAddressId(NEW_ADDRESS_ID)
314
+ }
315
+
316
+ return (
317
+ <div className="checkout-page__form space-y-4">
318
+ {savedAddresses.length > 0 && (
319
+ <div className="space-y-3">
320
+ <p className="text-xs font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-muted">
321
+ Saved addresses
322
+ </p>
323
+ <ul className="space-y-2">
324
+ {savedAddresses.map((address) => {
325
+ const isSelected = selectedAddressId === address.id
326
+ const isDefault = !!(address.is_default_shipping || address.is_default_billing)
327
+ const label = formatAddressName(address) || "Saved address"
328
+ const line = [
329
+ address.address_1,
330
+ [address.city, address.province, address.postal_code].filter(Boolean).join(", "),
331
+ ]
332
+ .filter(Boolean)
333
+ .join(" · ")
334
+
335
+ return (
336
+ <li key={address.id}>
337
+ <button
338
+ type="button"
339
+ onClick={() => applySavedAddress(address)}
340
+ disabled={isSelectingAddress}
341
+ aria-pressed={isSelected}
342
+ className={[
343
+ "w-full text-left rounded-lg border px-4 py-3 transition-colors duration-200",
344
+ "disabled:opacity-60",
345
+ isSelected
346
+ ? "border-brand-accent bg-brand-accent/5"
347
+ : "border-cart-border hover:border-brand-accent/50",
348
+ ].join(" ")}
349
+ >
350
+ <div className="flex items-start justify-between gap-3">
351
+ <div className="min-w-0 space-y-1">
352
+ <p className="text-sm font-semibold text-heading truncate">{label}</p>
353
+ <p className="text-xs text-muted leading-relaxed">{line}</p>
354
+ {isDefault && (
355
+ <p className="text-xs text-brand-accent font-medium">Default</p>
356
+ )}
357
+ </div>
358
+ <span
359
+ className={[
360
+ "mt-1 size-4 shrink-0 rounded-full border",
361
+ isSelected
362
+ ? "border-brand-accent bg-brand-accent"
363
+ : "border-cart-border bg-surface",
364
+ ].join(" ")}
365
+ aria-hidden
366
+ />
367
+ </div>
368
+ </button>
369
+ </li>
370
+ )
371
+ })}
372
+ <li>
373
+ <button
374
+ type="button"
375
+ onClick={handleSelectNewAddress}
376
+ aria-pressed={selectedAddressId === NEW_ADDRESS_ID}
377
+ className={[
378
+ "w-full text-left rounded-lg border px-4 py-3 transition-colors duration-200",
379
+ selectedAddressId === NEW_ADDRESS_ID
380
+ ? "border-brand-accent bg-brand-accent/5"
381
+ : "border-cart-border hover:border-brand-accent/50",
382
+ ].join(" ")}
383
+ >
384
+ <p className="text-sm font-semibold text-heading">Use a new address</p>
385
+ <p className="text-xs text-muted mt-1">Enter shipping details below</p>
386
+ </button>
387
+ </li>
388
+ </ul>
389
+ </div>
390
+ )}
391
+
392
+ <FloatingInput
393
+ label="Full name"
394
+ name="shipping_address.full_name"
395
+ id="shipping_full_name_field"
396
+ value={values.fullName}
397
+ onChange={(e) => updateField("fullName", e.target.value)}
398
+ required
399
+ maxLength={60}
400
+ />
401
+ <input type="hidden" name="shipping_address.first_name" value={values.firstName} />
402
+ <input type="hidden" name="shipping_address.last_name" value={values.lastName} />
403
+
404
+ <FloatingInput
405
+ label="Address"
406
+ name="shipping_address.address_1"
407
+ id="shipping_address_1_field"
408
+ value={values.address1}
409
+ onChange={(e) => updateField("address1", e.target.value)}
410
+ required
411
+ />
412
+
413
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
414
+ <FloatingInput
415
+ label="Postal code"
416
+ name="shipping_address.postal_code"
417
+ id="shipping_postal_code_field"
418
+ value={values.postalCode}
419
+ onChange={handlePostalCodeChange}
420
+ required
421
+ maxLength={6}
422
+ />
423
+ <FloatingInput
424
+ label="City"
425
+ name="shipping_address.city"
426
+ id="shipping_city_field"
427
+ value={values.city}
428
+ onChange={(e) => updateField("city", e.target.value)}
429
+ required
430
+ />
431
+ <FloatingInput
432
+ label="State / Province"
433
+ name="shipping_address.province"
434
+ id="shipping_province_field"
435
+ value={values.province}
436
+ onChange={(e) => updateField("province", e.target.value)}
437
+ required
438
+ />
439
+ </div>
440
+
441
+ <input type="hidden" name="shipping_address.country_code" value={values.countryCode} />
442
+ <input type="hidden" name="same_as_billing" value="on" />
443
+
444
+ <DeliveryInfo
445
+ postalCode={values.postalCode}
446
+ city={values.city}
447
+ province={values.province}
448
+ shippingTotal={cart.shipping_total}
449
+ currencyCode={cart.currency_code}
450
+ deliveryEstimate={deliveryEstimate}
451
+ formatDeliveryDate={formatDeliveryDate}
452
+ fallbackDeliveryDate={fallbackDeliveryDate}
453
+ isSyncing={isSyncingPincode || isSelectingAddress}
454
+ />
455
+
456
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
457
+ <FloatingInput
458
+ label="Email"
459
+ name="email"
460
+ id="shipping_email_field"
461
+ type="email"
462
+ value={values.email}
463
+ onChange={(e) => updateField("email", e.target.value)}
464
+ required
465
+ />
466
+ <SplitPhoneInput
467
+ value={values.phone}
468
+ onChange={(phone) => {
469
+ updateField("phone", phone)
470
+ if (isValidIndianPhone(phone)) setPhoneError(false)
471
+ }}
472
+ label="Phone"
473
+ required
474
+ error={phoneError}
475
+ numberInputId="shipping_phone_field"
476
+ hiddenInputId="shipping_phone_field_internal"
477
+ hiddenInputName="shipping_address.phone"
478
+ />
479
+ </div>
480
+
481
+ <input
482
+ type="hidden"
483
+ name="__phone_valid"
484
+ value={isValidIndianPhone(values.phone) ? "1" : "0"}
485
+ />
486
+ <input
487
+ type="hidden"
488
+ name="__email_valid"
489
+ value={isValidEmail(values.email) ? "1" : "0"}
490
+ />
491
+ </div>
492
+ )
493
+ }
494
+
495
+ export { splitFullName, isValidIndianPhone, isValidEmail }
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import { formatPrice } from "@pradip1995/segment-primitives/format-price"
4
+ import type { ShiprocketDeliveryEstimate } from "@pradip1995/commerce-core/util/shiprocket"
5
+
6
+ type DeliveryInfoProps = {
7
+ postalCode: string
8
+ city: string
9
+ province: string
10
+ shippingTotal?: number | null
11
+ currencyCode?: string
12
+ deliveryEstimate?: ShiprocketDeliveryEstimate
13
+ formatDeliveryDate?: (etd?: string | null) => string
14
+ fallbackDeliveryDate?: () => string
15
+ isSyncing?: boolean
16
+ }
17
+
18
+ export default function DeliveryInfo({
19
+ postalCode,
20
+ city,
21
+ province,
22
+ shippingTotal,
23
+ currencyCode = "inr",
24
+ isSyncing,
25
+ }: DeliveryInfoProps) {
26
+ if (postalCode.length !== 6) return null
27
+
28
+ return (
29
+ <div className="checkout-delivery-info">
30
+ <div className="checkout-delivery-info__head">
31
+ <span className="checkout-delivery-info__title">Delivery details</span>
32
+ {(city || province) && (
33
+ <span className="checkout-delivery-info__location">
34
+ {[city, province].filter(Boolean).join(", ")}
35
+ </span>
36
+ )}
37
+ </div>
38
+
39
+ <div className="checkout-delivery-info__rows">
40
+ <div className="checkout-delivery-info__row">
41
+ <span className="checkout-delivery-info__label">Pincode</span>
42
+ <span className="checkout-delivery-info__value">{postalCode}</span>
43
+ </div>
44
+
45
+ <div className="checkout-delivery-info__row">
46
+ <span className="checkout-delivery-info__label">Shipping charge</span>
47
+ <span className="checkout-delivery-info__value checkout-delivery-info__value--accent">
48
+ {isSyncing ? (
49
+ <span className="checkout-delivery-info__loading">Calculating…</span>
50
+ ) : shippingTotal != null && shippingTotal > 0 ? (
51
+ formatPrice(shippingTotal, currencyCode)
52
+ ) : shippingTotal === 0 ? (
53
+ "Free"
54
+ ) : (
55
+ "—"
56
+ )}
57
+ </span>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,53 @@
1
+ "use client"
2
+
3
+ type FloatingInputProps = {
4
+ label: string
5
+ name: string
6
+ id?: string
7
+ type?: string
8
+ value: string
9
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
10
+ required?: boolean
11
+ maxLength?: number
12
+ autoComplete?: string
13
+ readOnly?: boolean
14
+ className?: string
15
+ }
16
+
17
+ export default function FloatingInput({
18
+ label,
19
+ name,
20
+ id,
21
+ type = "text",
22
+ value,
23
+ onChange,
24
+ required,
25
+ maxLength,
26
+ autoComplete = "off",
27
+ readOnly,
28
+ className = "",
29
+ }: FloatingInputProps) {
30
+ const inputId = id || name
31
+
32
+ return (
33
+ <div className={`checkout-field ${className}`.trim()}>
34
+ <input
35
+ type={type}
36
+ id={inputId}
37
+ name={name}
38
+ value={value}
39
+ onChange={onChange}
40
+ required={required}
41
+ maxLength={maxLength}
42
+ autoComplete={autoComplete}
43
+ readOnly={readOnly}
44
+ placeholder=" "
45
+ className="checkout-field__input peer"
46
+ />
47
+ <label htmlFor={inputId} className="checkout-field__label">
48
+ {label}
49
+ {required && <span className="text-red-500">*</span>}
50
+ </label>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,278 @@
1
+ "use client"
2
+
3
+ import {
4
+ initiatePaymentSession,
5
+ placeOrder,
6
+ retrieveCart,
7
+ } from "@pradip1995/commerce-core/client/actions/cart"
8
+ import {
9
+ findPaymentSession,
10
+ getPaymentButtonLabel,
11
+ getPaymentGatewayKind,
12
+ getProviderInitAttempts,
13
+ } from "@pradip1995/commerce-core/util/payment-providers"
14
+ import { isNextRedirect } from "@pradip1995/segment-primitives/is-next-redirect"
15
+ import type { HttpTypes } from "@medusajs/types"
16
+
17
+ type CartLike = HttpTypes.StoreCart & {
18
+ payment_collection?: {
19
+ payment_sessions?: HttpTypes.StorePaymentSession[]
20
+ } | null
21
+ }
22
+
23
+ const CART_PAYMENT_FIELDS =
24
+ "*payment_collection,*payment_collection.payment_sessions,*shipping_address,*billing_address,*shipping_methods,email,+total"
25
+
26
+ async function retrieveCartWithPayments(cartId: string): Promise<CartLike | null> {
27
+ return (await retrieveCart(cartId, CART_PAYMENT_FIELDS)) as CartLike | null
28
+ }
29
+
30
+ async function initiatePaymentSessionWithFallback(
31
+ cart: CartLike,
32
+ providerId: string
33
+ ): Promise<CartLike> {
34
+ const attempts = getProviderInitAttempts(providerId)
35
+ let lastError: unknown
36
+
37
+ for (const attemptId of attempts) {
38
+ try {
39
+ await initiatePaymentSession(cart, { provider_id: attemptId })
40
+ const fresh = await retrieveCartWithPayments(cart.id)
41
+ if (fresh) return fresh
42
+ } catch (error) {
43
+ lastError = error
44
+ }
45
+ }
46
+
47
+ if (lastError instanceof Error) {
48
+ throw lastError
49
+ }
50
+
51
+ throw new Error(`Could not initialize ${getPaymentButtonLabel(providerId)}`)
52
+ }
53
+
54
+ function loadScript(src: string): Promise<void> {
55
+ return new Promise((resolve, reject) => {
56
+ if (document.querySelector(`script[src="${src}"]`)) {
57
+ resolve()
58
+ return
59
+ }
60
+
61
+ const script = document.createElement("script")
62
+ script.src = src
63
+ script.onload = () => resolve()
64
+ script.onerror = () => reject(new Error(`Failed to load ${src}`))
65
+ document.body.appendChild(script)
66
+ })
67
+ }
68
+
69
+ async function openRazorpayCheckout(options: {
70
+ cart: CartLike
71
+ session: HttpTypes.StorePaymentSession
72
+ shopName?: string
73
+ onDismiss?: () => void
74
+ }): Promise<void> {
75
+ const { cart, session, shopName = "Store", onDismiss } = options
76
+ const data = (session.data ?? {}) as Record<string, unknown>
77
+ const orderId =
78
+ (data.razorpay_order_id as string | undefined) ||
79
+ (data.order_id as string | undefined) ||
80
+ (typeof data.id === "string" && data.id.startsWith("order_") ? data.id : undefined)
81
+ const razorpayKey =
82
+ process.env.NEXT_PUBLIC_RAZORPAY_KEY?.trim() ||
83
+ (typeof data.key_id === "string" ? data.key_id.trim() : "")
84
+
85
+ if (!razorpayKey) {
86
+ throw new Error(
87
+ "Razorpay key is missing. Set NEXT_PUBLIC_RAZORPAY_KEY (same as RAZORPAY_KEY_ID) in storefront env."
88
+ )
89
+ }
90
+
91
+ if (!orderId) {
92
+ throw new Error(
93
+ "Razorpay order was not created. Check RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET on the Medusa backend."
94
+ )
95
+ }
96
+
97
+ await loadScript("https://checkout.razorpay.com/v1/checkout.js")
98
+
99
+ const razorpayOptions = {
100
+ key: razorpayKey,
101
+ amount: session.amount,
102
+ order_id: orderId,
103
+ currency: (cart.currency_code ?? "inr").toUpperCase(),
104
+ name: shopName,
105
+ description: "Secure checkout for your order",
106
+ handler: async () => {
107
+ await placeOrder().catch((err: Error) => {
108
+ if (isNextRedirect(err)) throw err
109
+ throw err
110
+ })
111
+ },
112
+ prefill: {
113
+ name: `${cart.billing_address?.first_name || ""} ${cart.billing_address?.last_name || ""}`.trim(),
114
+ email: cart.email,
115
+ contact: cart.shipping_address?.phone || cart.billing_address?.phone || undefined,
116
+ },
117
+ theme: { color: "var(--color-brand-accent, #8B5AB1)" },
118
+ modal: {
119
+ ondismiss: () => {
120
+ onDismiss?.()
121
+ },
122
+ },
123
+ }
124
+
125
+ const razorpay = new (window as any).Razorpay(razorpayOptions)
126
+ razorpay.on("payment.failed", () => {
127
+ onDismiss?.()
128
+ })
129
+ razorpay.open()
130
+ }
131
+
132
+ async function openCashfreeCheckout(options: {
133
+ session: HttpTypes.StorePaymentSession
134
+ onDismiss?: () => void
135
+ }): Promise<void> {
136
+ const { session, onDismiss } = options
137
+ const paymentSessionId =
138
+ (session.data?.payment_session_id as string | undefined) ||
139
+ (session.data?.paymentSessionId as string | undefined)
140
+
141
+ if (!paymentSessionId) {
142
+ throw new Error(
143
+ "Cashfree session was not created. Check CASHFREE_CLIENT_ID / CASHFREE_CLIENT_SECRET on the Medusa backend and restart it."
144
+ )
145
+ }
146
+
147
+ await loadScript("https://sdk.cashfree.com/js/v3/cashfree.js")
148
+
149
+ const CashfreeCtor = (window as any).Cashfree
150
+ if (typeof CashfreeCtor !== "function") {
151
+ throw new Error("Cashfree SDK failed to load.")
152
+ }
153
+
154
+ const mode =
155
+ process.env.NEXT_PUBLIC_CASHFREE_ENVIRONMENT === "production" ? "production" : "sandbox"
156
+ const cashfree = CashfreeCtor({ mode })
157
+
158
+ const result = await cashfree.checkout({
159
+ paymentSessionId,
160
+ redirectTarget: "_modal",
161
+ })
162
+
163
+ if (result?.error) {
164
+ onDismiss?.()
165
+ throw new Error(result.error.message || "Cashfree payment was cancelled or failed.")
166
+ }
167
+
168
+ if (result?.paymentDetails || result?.redirect === false) {
169
+ await placeOrder().catch((err: unknown) => {
170
+ if (isNextRedirect(err)) throw err
171
+ throw err
172
+ })
173
+ return
174
+ }
175
+
176
+ // Redirect checkout: Cashfree navigates away; order is finalized on return.
177
+ }
178
+
179
+ export async function openHostedCheckout(options: {
180
+ cart: CartLike
181
+ providerId: string
182
+ shopName?: string
183
+ onDismiss?: () => void
184
+ }): Promise<void> {
185
+ const { cart, providerId, shopName, onDismiss } = options
186
+ const kind = getPaymentGatewayKind(providerId)
187
+
188
+ const updatedCart = await initiatePaymentSessionWithFallback(cart, providerId)
189
+ const session = findPaymentSession(updatedCart, providerId) as
190
+ | HttpTypes.StorePaymentSession
191
+ | undefined
192
+
193
+ if (!session) {
194
+ throw new Error(`Could not initialize ${getPaymentButtonLabel(providerId)}`)
195
+ }
196
+
197
+ if (kind === "razorpay") {
198
+ await openRazorpayCheckout({ cart: updatedCart, session, shopName, onDismiss })
199
+ return
200
+ }
201
+
202
+ if (kind === "cashfree") {
203
+ await openCashfreeCheckout({ session, onDismiss })
204
+ return
205
+ }
206
+
207
+ throw new Error(`Unsupported payment provider: ${providerId}`)
208
+ }
209
+
210
+ export async function placeCodOrder(options: {
211
+ cart: CartLike
212
+ providerId: string
213
+ }): Promise<void> {
214
+ const { cart, providerId } = options
215
+
216
+ await initiatePaymentSessionWithFallback(cart, providerId)
217
+ await placeOrder().catch((err: unknown) => {
218
+ if (isNextRedirect(err)) throw err
219
+ throw err
220
+ })
221
+ }
222
+
223
+ export function partitionPaymentProviders(
224
+ providers: Array<{ id: string }> | null | undefined
225
+ ) {
226
+ const manual: Array<{ id: string }> = []
227
+ const hosted: Array<{ id: string }> = []
228
+
229
+ for (const provider of providers ?? []) {
230
+ const kind = getPaymentGatewayKind(provider.id)
231
+ if (kind === "manual") {
232
+ manual.push(provider)
233
+ } else if (kind === "razorpay" || kind === "cashfree") {
234
+ hosted.push(provider)
235
+ }
236
+ }
237
+
238
+ return { manual, hosted }
239
+ }
240
+
241
+ export function pickHostedPaymentProvider(
242
+ providers: Array<{ id: string }> | null | undefined
243
+ ): string | null {
244
+ const { hosted } = partitionPaymentProviders(providers)
245
+ const preferred = process.env.NEXT_PUBLIC_PAYMENT_PROVIDER?.trim().toLowerCase()
246
+
247
+ if (preferred === "razorpay") {
248
+ const razorpay = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "razorpay")
249
+ if (razorpay) return razorpay.id
250
+ }
251
+
252
+ if (preferred === "cashfree") {
253
+ const cashfree = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "cashfree")
254
+ if (cashfree) return cashfree.id
255
+ }
256
+
257
+ const cashfree = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "cashfree")
258
+ if (cashfree) return cashfree.id
259
+
260
+ const razorpay = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "razorpay")
261
+ if (razorpay) return razorpay.id
262
+
263
+ if (hosted.length > 0) return hosted[0].id
264
+
265
+ if (process.env.NEXT_PUBLIC_RAZORPAY_KEY) {
266
+ return "pp_razorpay_razorpay"
267
+ }
268
+
269
+ return null
270
+ }
271
+
272
+ export function pickManualPaymentProvider(
273
+ providers: Array<{ id: string }> | null | undefined
274
+ ): string | null {
275
+ const { manual } = partitionPaymentProviders(providers)
276
+ if (manual.length > 0) return manual[0].id
277
+ return "pp_system_default"
278
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,36 @@
1
+ .jewelry-checkout-form {
2
+ height: 100%;
3
+ }
4
+
5
+ .jewelry-checkout-payment__btn {
6
+ min-height: 3.15rem;
7
+ border-radius: 0.5rem !important;
8
+ letter-spacing: 0.12em;
9
+ text-transform: uppercase;
10
+ font-size: 0.75rem;
11
+ font-weight: 600;
12
+ }
13
+
14
+ .jewelry-checkout-payment__btn--cod {
15
+ background: #ffffff !important;
16
+ color: #1a1224 !important;
17
+ border: 1px solid rgba(59, 31, 78, 0.28) !important;
18
+ }
19
+
20
+ .jewelry-checkout-payment__btn--cod:hover:not(:disabled) {
21
+ border-color: #3b1f4e !important;
22
+ color: #3b1f4e !important;
23
+ background: #ffffff !important;
24
+ }
25
+
26
+ .jewelry-checkout-payment__btn--online {
27
+ background: #3b1f4e !important;
28
+ border: 1px solid #3b1f4e !important;
29
+ color: #ffffff !important;
30
+ }
31
+
32
+ .jewelry-checkout-payment__btn--online:hover:not(:disabled) {
33
+ background: #55306c !important;
34
+ border-color: #55306c !important;
35
+ color: #ffffff !important;
36
+ }
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "jewelry-checkout-form",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "checkout",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1,106 @@
1
+ "use client"
2
+
3
+ import { useState, useTransition } from "react"
4
+ import { formatPrice } from "@pradip1995/segment-primitives/format-price"
5
+ import { isNextRedirect } from "@pradip1995/segment-primitives/is-next-redirect"
6
+ import type { HttpTypes } from "@medusajs/types"
7
+ import {
8
+ openHostedCheckout,
9
+ pickHostedPaymentProvider,
10
+ pickManualPaymentProvider,
11
+ placeCodOrder,
12
+ } from "./hosted-checkout"
13
+ import { prepareCheckoutFromForm } from "./prepare-checkout"
14
+ import { trackAddPaymentInfo } from "@pradip1995/segment-analytics/ecommerce-events"
15
+ import { mapCartToEcommercePayload } from "@pradip1995/commerce-core/analytics/mappers"
16
+ import { getPaymentGatewayKind } from "@pradip1995/commerce-core/util/payment-providers"
17
+ import "./jewelry-checkout-form.css"
18
+
19
+ type PaymentBlockProps = {
20
+ cart: HttpTypes.StoreCart
21
+ paymentProviders?: Array<{ id: string }>
22
+ disabled?: boolean
23
+ shopName?: string
24
+ }
25
+
26
+ export default function PaymentBlock({ cart, paymentProviders, disabled, shopName }: PaymentBlockProps) {
27
+ const [error, setError] = useState<string | null>(null)
28
+ const [pending, startTransition] = useTransition()
29
+
30
+ const providerList = paymentProviders ?? cart.region?.payment_providers ?? []
31
+ const onlineProviderId = pickHostedPaymentProvider(providerList)
32
+ const codProviderId = pickManualPaymentProvider(providerList)
33
+ const totalLabel = formatPrice(cart.total, cart.currency_code)
34
+ const onlineKind = getPaymentGatewayKind(onlineProviderId ?? undefined)
35
+ const onlineHint =
36
+ onlineKind === "cashfree"
37
+ ? `Your delivery address is saved when you pay. Online payments use Cashfree (${
38
+ process.env.NEXT_PUBLIC_CASHFREE_ENVIRONMENT === "production" ? "live" : "test"
39
+ } mode).`
40
+ : onlineKind === "razorpay"
41
+ ? "Your delivery address is saved when you pay. Online payments use Razorpay (test mode)."
42
+ : "Your delivery address is saved when you pay."
43
+
44
+ const runCheckout = (mode: "online" | "cod") => {
45
+ setError(null)
46
+ startTransition(async () => {
47
+ try {
48
+ const freshCart = await prepareCheckoutFromForm(cart.id)
49
+ trackAddPaymentInfo(mapCartToEcommercePayload(freshCart))
50
+
51
+ if (mode === "cod") {
52
+ await placeCodOrder({ cart: freshCart, providerId: codProviderId! })
53
+ return
54
+ }
55
+
56
+ if (!onlineProviderId) {
57
+ throw new Error("Online payment is not configured.")
58
+ }
59
+
60
+ await openHostedCheckout({ cart: freshCart, providerId: onlineProviderId, shopName })
61
+ } catch (err) {
62
+ if (isNextRedirect(err)) throw err
63
+ setError(err instanceof Error ? err.message : "Payment could not be started.")
64
+ }
65
+ })
66
+ }
67
+
68
+ const onlineLabel =
69
+ onlineKind === "razorpay"
70
+ ? "Pay with Razorpay"
71
+ : onlineKind === "cashfree"
72
+ ? "Pay with Cashfree"
73
+ : "Pay online"
74
+
75
+ return (
76
+ <div className="checkout-payment jewelry-checkout-payment">
77
+ <div className="checkout-payment__actions">
78
+ {codProviderId ? (
79
+ <button
80
+ type="button"
81
+ onClick={() => runCheckout("cod")}
82
+ disabled={disabled || pending}
83
+ className="checkout-page__btn checkout-page__btn--outline checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--cod"
84
+ >
85
+ {pending ? "Processing…" : `Cash on delivery · ${totalLabel}`}
86
+ </button>
87
+ ) : null}
88
+
89
+ {onlineProviderId ? (
90
+ <button
91
+ type="button"
92
+ onClick={() => runCheckout("online")}
93
+ disabled={disabled || pending}
94
+ className="checkout-page__btn checkout-page__btn--primary checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--online"
95
+ >
96
+ {pending ? "Processing…" : onlineLabel}
97
+ </button>
98
+ ) : null}
99
+ </div>
100
+
101
+ <p className="checkout-payment__hint text-xs text-muted mt-3 text-center">{onlineHint}</p>
102
+
103
+ {error && <p className="checkout-payment__error text-sm text-red-600 mt-2">{error}</p>}
104
+ </div>
105
+ )
106
+ }
@@ -0,0 +1,31 @@
1
+ import { loaderBus } from "@pradip1995/commerce-core/util/loader-bus"
2
+
3
+ export type PincodeLocation = {
4
+ city: string
5
+ province: string
6
+ }
7
+
8
+ export async function fetchPincodeLocation(pincode: string): Promise<PincodeLocation | null> {
9
+ if (pincode.length !== 6 || !/^\d{6}$/.test(pincode)) {
10
+ return null
11
+ }
12
+
13
+ try {
14
+ loaderBus.begin()
15
+ const response = await fetch(`https://api.postalpincode.in/pincode/${pincode}`)
16
+ const data = await response.json()
17
+ if (data?.[0]?.Status === "Success" && data[0].PostOffice?.[0]) {
18
+ const postOffice = data[0].PostOffice[0]
19
+ return {
20
+ city: postOffice.District || postOffice.Name || "",
21
+ province: postOffice.State || "",
22
+ }
23
+ }
24
+ } catch {
25
+ // ignore network errors
26
+ } finally {
27
+ loaderBus.end()
28
+ }
29
+
30
+ return null
31
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ assignDefaultShippingMethod,
3
+ retrieveCart,
4
+ setAddresses,
5
+ } from "@pradip1995/commerce-core/client/actions/cart"
6
+ import type { HttpTypes } from "@medusajs/types"
7
+ import { isValidEmail, isValidIndianPhone } from "./address-fields"
8
+
9
+ const CART_CHECKOUT_FIELDS =
10
+ "*payment_collection,*payment_collection.payment_sessions,*shipping_address,*billing_address,*shipping_methods,email,+total,+shipping_total"
11
+
12
+ export function validateCheckoutForm(form: HTMLFormElement): boolean {
13
+ const phoneInput = document.getElementById("shipping_phone_field_internal") as HTMLInputElement | null
14
+ const phone = phoneInput?.value || ""
15
+
16
+ if (!isValidIndianPhone(phone)) {
17
+ form.reportValidity()
18
+ return false
19
+ }
20
+
21
+ const emailInput = form.querySelector('[name="email"]') as HTMLInputElement | null
22
+ if (emailInput && !isValidEmail(emailInput.value)) {
23
+ emailInput.setCustomValidity("Enter a valid email address")
24
+ emailInput.reportValidity()
25
+ emailInput.setCustomValidity("")
26
+ return false
27
+ }
28
+
29
+ if (!form.checkValidity()) {
30
+ form.reportValidity()
31
+ return false
32
+ }
33
+
34
+ return true
35
+ }
36
+
37
+ export async function prepareCheckoutFromForm(
38
+ cartId: string
39
+ ): Promise<HttpTypes.StoreCart> {
40
+ const form = document.getElementById("checkout-address-form") as HTMLFormElement | null
41
+ if (!form) {
42
+ throw new Error("Checkout form not found.")
43
+ }
44
+
45
+ if (!validateCheckoutForm(form)) {
46
+ throw new Error("Please complete all required delivery fields.")
47
+ }
48
+
49
+ const saveError = await setAddresses(null, new FormData(form))
50
+ if (saveError) {
51
+ throw new Error(saveError)
52
+ }
53
+
54
+ const shippingResult = await assignDefaultShippingMethod(cartId)
55
+ if (!shippingResult?.success) {
56
+ throw new Error(
57
+ "Could not calculate shipping. Enter a valid 6-digit pincode and try again."
58
+ )
59
+ }
60
+
61
+ const freshCart = await retrieveCart(cartId, CART_CHECKOUT_FIELDS)
62
+ if (!freshCart) {
63
+ throw new Error("Cart not found.")
64
+ }
65
+
66
+ if (!freshCart.email?.trim()) {
67
+ throw new Error("Email is required.")
68
+ }
69
+
70
+ if (!freshCart.shipping_address?.phone?.trim()) {
71
+ throw new Error("Phone number is required.")
72
+ }
73
+
74
+ if ((freshCart.shipping_methods?.length ?? 0) < 1) {
75
+ throw new Error(
76
+ "Shipping is not set. Enter a valid pincode and wait for delivery options."
77
+ )
78
+ }
79
+
80
+ return freshCart
81
+ }
@@ -0,0 +1,38 @@
1
+ "use client"
2
+
3
+ import type { HttpTypes } from "@medusajs/types"
4
+ import AddressFields from "./address-fields"
5
+ import "./jewelry-checkout-form.css"
6
+
7
+ export default function JewelryCheckoutForm({
8
+ cart,
9
+ customer,
10
+ formTitle = "Contact & shipping",
11
+ }: {
12
+ cart?: HttpTypes.StoreCart
13
+ customer?: HttpTypes.StoreCustomer | null
14
+ formTitle?: string
15
+ shippingMethods?: HttpTypes.StoreCartShippingOption[]
16
+ paymentProviders?: Array<{ id: string }>
17
+ shopName?: string
18
+ }) {
19
+ if (!cart?.items?.length) {
20
+ return (
21
+ <div className="card-surface rounded-lg p-6">
22
+ <p className="text-muted text-sm">Your cart is empty.</p>
23
+ </div>
24
+ )
25
+ }
26
+
27
+ return (
28
+ <div className="jewelry-checkout-form checkout-page__address-panel card-surface rounded-lg p-6 space-y-6">
29
+ <h2 className="checkout-page__title text-sm font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-heading">
30
+ {formTitle}
31
+ </h2>
32
+
33
+ <form id="checkout-address-form" className="space-y-4" autoComplete="off">
34
+ <AddressFields cart={cart} customer={customer} />
35
+ </form>
36
+ </div>
37
+ )
38
+ }