@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 +42 -0
- package/src/address-fields.tsx +495 -0
- package/src/delivery-info.tsx +62 -0
- package/src/floating-input.tsx +53 -0
- package/src/hosted-checkout.ts +278 -0
- package/src/index.ts +2 -0
- package/src/jewelry-checkout-form.css +36 -0
- package/src/manifest.ts +11 -0
- package/src/payment-block.tsx +106 -0
- package/src/pincode-location.ts +31 -0
- package/src/prepare-checkout.ts +81 -0
- package/src/segment.tsx +38 -0
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,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
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|
package/src/segment.tsx
ADDED
|
@@ -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
|
+
}
|