medusa-ui-common 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +93 -0
  2. package/src/common/components/breadcrumb/index.tsx +43 -0
  3. package/src/common/components/cart-totals/index.tsx +562 -0
  4. package/src/common/components/checkbox/index.tsx +98 -0
  5. package/src/common/components/delete-button/index.tsx +158 -0
  6. package/src/common/components/discount-code/index.tsx +220 -0
  7. package/src/common/components/divider/index.tsx +9 -0
  8. package/src/common/components/error-message/index.tsx +13 -0
  9. package/src/common/components/filter-checkbox-group/index.tsx +134 -0
  10. package/src/common/components/filter-radio-group/index.tsx +62 -0
  11. package/src/common/components/input/index.tsx +79 -0
  12. package/src/common/components/interactive-link/index.tsx +33 -0
  13. package/src/common/components/line-item-options/index.tsx +26 -0
  14. package/src/common/components/line-item-price/index.tsx +64 -0
  15. package/src/common/components/line-item-unit-price/index.tsx +64 -0
  16. package/src/common/components/localized-client-link/index.tsx +32 -0
  17. package/src/common/components/login-popup/index.tsx +78 -0
  18. package/src/common/components/modal/index.tsx +123 -0
  19. package/src/common/components/native-select/index.tsx +75 -0
  20. package/src/common/components/obfuscated-email/index.tsx +30 -0
  21. package/src/common/components/processing-overlay/index.tsx +83 -0
  22. package/src/common/components/radio/index.tsx +27 -0
  23. package/src/common/components/side-panel/index.tsx +65 -0
  24. package/src/common/components/submit-button/index.tsx +32 -0
  25. package/src/common/icons/arrow-left.tsx +36 -0
  26. package/src/common/icons/back.tsx +37 -0
  27. package/src/common/icons/bancontact.tsx +26 -0
  28. package/src/common/icons/chevron-down.tsx +30 -0
  29. package/src/common/icons/delivered.tsx +29 -0
  30. package/src/common/icons/envelope.tsx +27 -0
  31. package/src/common/icons/eye-off.tsx +37 -0
  32. package/src/common/icons/eye.tsx +37 -0
  33. package/src/common/icons/fast-delivery.tsx +65 -0
  34. package/src/common/icons/ideal.tsx +26 -0
  35. package/src/common/icons/lock.tsx +31 -0
  36. package/src/common/icons/map-pin.tsx +37 -0
  37. package/src/common/icons/medusa.tsx +27 -0
  38. package/src/common/icons/menu.tsx +45 -0
  39. package/src/common/icons/nextjs.tsx +27 -0
  40. package/src/common/icons/package.tsx +44 -0
  41. package/src/common/icons/paypal.tsx +30 -0
  42. package/src/common/icons/phone.tsx +30 -0
  43. package/src/common/icons/placeholder-image.tsx +44 -0
  44. package/src/common/icons/refresh.tsx +51 -0
  45. package/src/common/icons/spinner.tsx +37 -0
  46. package/src/common/icons/trash.tsx +51 -0
  47. package/src/common/icons/user.tsx +37 -0
  48. package/src/common/icons/x.tsx +37 -0
  49. package/src/constants/payments.tsx +31 -0
  50. package/src/context/modal-context.tsx +37 -0
  51. package/src/context/wishlist-context.tsx +83 -0
  52. package/src/index.ts +16 -0
  53. package/src/skeletons/components/skeleton-button/index.tsx +5 -0
  54. package/src/skeletons/components/skeleton-card-details/index.tsx +10 -0
  55. package/src/skeletons/components/skeleton-cart-item/index.tsx +35 -0
  56. package/src/skeletons/components/skeleton-cart-totals/index.tsx +30 -0
  57. package/src/skeletons/components/skeleton-code-form/index.tsx +13 -0
  58. package/src/skeletons/components/skeleton-line-item/index.tsx +35 -0
  59. package/src/skeletons/components/skeleton-order-confirmed-header/index.tsx +14 -0
  60. package/src/skeletons/components/skeleton-order-information/index.tsx +36 -0
  61. package/src/skeletons/components/skeleton-order-items/index.tsx +43 -0
  62. package/src/skeletons/components/skeleton-order-summary/index.tsx +15 -0
  63. package/src/skeletons/components/skeleton-product-preview/index.tsx +15 -0
  64. package/src/skeletons/templates/skeleton-cart-page/index.tsx +65 -0
  65. package/src/skeletons/templates/skeleton-order-confirmed/index.tsx +21 -0
  66. package/src/skeletons/templates/skeleton-product-grid/index.tsx +23 -0
  67. package/src/skeletons/templates/skeleton-related-products/index.tsx +25 -0
  68. package/src/types/global.ts +24 -0
  69. package/src/types/icon.ts +6 -0
  70. package/src/util/checkout-dom.ts +65 -0
  71. package/src/util/compare-addresses.ts +28 -0
  72. package/src/util/env.ts +3 -0
  73. package/src/util/get-percentage-diff.ts +6 -0
  74. package/src/util/get-product-price.ts +79 -0
  75. package/src/util/isEmpty.ts +11 -0
  76. package/src/util/money.ts +26 -0
  77. package/src/util/product.ts +86 -0
  78. package/src/util/repeat.ts +5 -0
  79. package/src/util/returns.ts +72 -0
@@ -0,0 +1,562 @@
1
+ "use client"
2
+
3
+ import { convertToLocale } from "medusa-ui-common/util/money"
4
+ import { syncCheckoutAddressFromDom } from "medusa-ui-common/util/checkout-dom"
5
+ import React from "react"
6
+ import { HttpTypes } from "@medusajs/types"
7
+ import { clx } from "@medusajs/ui"
8
+ import LocalizedClientLink from "medusa-ui-common/common/components/localized-client-link"
9
+ import { useRouter, usePathname, useSearchParams } from "next/navigation"
10
+ import ProcessingOverlay from "medusa-ui-common/common/components/processing-overlay"
11
+ import { useSiteCompany } from "medusa-storefront-core"
12
+
13
+ type CartTotalsProps = {
14
+ totals: {
15
+ total?: number | null
16
+ subtotal?: number | null
17
+ tax_total?: number | null
18
+ currency_code: string
19
+ item_subtotal?: number | null
20
+ shipping_subtotal?: number | null
21
+ discount_subtotal?: number | null
22
+ }
23
+ items?: HttpTypes.StoreCartLineItem[]
24
+ checkoutStep?: string
25
+ customer?: HttpTypes.StoreCustomer | null
26
+ }
27
+
28
+ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutStep, customer }) => {
29
+ const company = useSiteCompany()
30
+ const router = useRouter()
31
+ const pathname = usePathname()
32
+ const searchParams = useSearchParams()
33
+ const activeStep = searchParams.get("step")
34
+
35
+ // Check if we're on the payment page, order page, or cart page
36
+ const isOnPaymentPage = pathname.includes("/checkout/payment")
37
+ const isOnOrderPage = pathname.includes("/order/")
38
+ const isOnCartPage = pathname.includes("/cart")
39
+
40
+ const {
41
+ currency_code,
42
+ total,
43
+ tax_total,
44
+ item_subtotal,
45
+ shipping_subtotal,
46
+ discount_subtotal,
47
+ } = totals
48
+
49
+ // Calculate Total MRP (sum of original_total from all items)
50
+ const totalMRP = items.reduce((sum, item) => {
51
+ return sum + (item.original_total || item.total || 0)
52
+ }, 0)
53
+
54
+ // Calculate total discount (difference between MRP and current subtotal)
55
+ const calculatedDiscount = totalMRP > (item_subtotal || 0)
56
+ ? totalMRP - (item_subtotal || 0)
57
+ : (discount_subtotal || 0)
58
+
59
+ // Use GST label for taxes (as shown in the image)
60
+ const gstAmount = tax_total || 0
61
+ const shippingAmount = shipping_subtotal || 0
62
+ const finalTotal = total || 0
63
+ const itemCount = items.length
64
+
65
+ const [isRedirecting, setIsRedirecting] = React.useState(false)
66
+ const [showOverlay, setShowOverlay] = React.useState(false)
67
+ const [processingVariant, setProcessingVariant] = React.useState<"payment" | "order">("payment")
68
+ const [isAutoSettingShipping, setIsAutoSettingShipping] = React.useState(false)
69
+
70
+ // Auto-set shipping method if on payment step and none is selected
71
+ const hasAutoSet = React.useRef<string | null>(null)
72
+
73
+ // Listen for real-time shipping updates
74
+ React.useEffect(() => {
75
+ const handlePostalUpdate = () => {
76
+ // Show loading state in totals
77
+ setIsAutoSettingShipping(true)
78
+ // Reset auto-set ref to allow re-selection of shipping for new pincode
79
+ hasAutoSet.current = ""
80
+ // Trigger a single router refresh as a safeguard
81
+ router.refresh()
82
+ }
83
+
84
+ window.addEventListener('postal-code-updated', handlePostalUpdate)
85
+ window.addEventListener('address-updated-silent', () => {
86
+ setIsAutoSettingShipping(true)
87
+ // Reset auto-set ref
88
+ hasAutoSet.current = ""
89
+ })
90
+ return () => {
91
+ window.removeEventListener('postal-code-updated', handlePostalUpdate)
92
+ window.removeEventListener('address-updated-silent', () => {})
93
+ }
94
+ }, [router])
95
+
96
+ // Safety reset for Calculating state
97
+ React.useEffect(() => {
98
+ const cart = totals as any
99
+ if (cart?.shipping_methods && cart.shipping_methods.length > 0) {
100
+ if (isAutoSettingShipping) {
101
+ setIsAutoSettingShipping(false)
102
+ }
103
+ }
104
+ }, [totals, isAutoSettingShipping])
105
+
106
+ React.useEffect(() => {
107
+ autoSetShipping()
108
+ }, [activeStep, totals, router])
109
+
110
+
111
+ const isSetting = React.useRef(false)
112
+
113
+ const autoSetShipping = async (retryCount = 0) => {
114
+ const cart = totals as any
115
+ if (!cart?.id || isSetting.current) return
116
+
117
+ // If shipping methods already exist, we are done
118
+ if (cart?.shipping_methods && cart.shipping_methods.length > 0) {
119
+ setIsAutoSettingShipping(false)
120
+ return
121
+ }
122
+
123
+ const { listCartOptions, setShippingMethod } = await import("medusa-storefront-data/cart")
124
+ const currentKey = cart.id + cart.shipping_address?.postal_code
125
+
126
+ const isCheckoutPage = pathname.includes("/checkout")
127
+ const shouldAutoSet = (activeStep === "payment" || isCheckoutPage) && cart?.shipping_address?.postal_code?.length === 6
128
+
129
+ if (shouldAutoSet && hasAutoSet.current !== currentKey) {
130
+
131
+ isSetting.current = true
132
+ setIsAutoSettingShipping(true)
133
+
134
+ try {
135
+ const { shipping_options } = await listCartOptions()
136
+
137
+ if (shipping_options && shipping_options.length > 0) {
138
+ await setShippingMethod({ cartId: cart.id, shippingMethodId: shipping_options[0].id })
139
+ hasAutoSet.current = currentKey // Mark as done for this pincode
140
+ router.refresh()
141
+ // Final unlock delay
142
+ setTimeout(() => {
143
+ setIsAutoSettingShipping(false)
144
+ isSetting.current = false
145
+ }, 1500)
146
+ } else if (retryCount < 6) {
147
+ // Keep trying if no options yet
148
+ setTimeout(() => {
149
+ isSetting.current = false
150
+ autoSetShipping(retryCount + 1)
151
+ }, 1500)
152
+ } else {
153
+ setIsAutoSettingShipping(false)
154
+ isSetting.current = false
155
+ }
156
+ } catch (e) {
157
+ setIsAutoSettingShipping(false)
158
+ isSetting.current = false
159
+ }
160
+ } else {
161
+ setIsAutoSettingShipping(false)
162
+ isSetting.current = false
163
+ }
164
+ }
165
+
166
+ React.useEffect(() => {
167
+ autoSetShipping()
168
+ }, [activeStep, totals, router])
169
+
170
+ const syncLastEtdToCart = async () => {
171
+ try {
172
+ const cart = totals as any
173
+ const cookiesArr = document.cookie.split('; ')
174
+ const etdCookie = cookiesArr.find(row => row.startsWith('_medusa_last_etd='))
175
+ const lastEtdValue = etdCookie ? etdCookie.split('=')[1] : null
176
+
177
+ if (lastEtdValue && cart?.id && cart.metadata?.estimated_delivery !== lastEtdValue) {
178
+ const { updateCartMetadataSilently } = await import("medusa-storefront-data/cart")
179
+ await updateCartMetadataSilently({
180
+ ...cart.metadata,
181
+ estimated_delivery: lastEtdValue
182
+ })
183
+ }
184
+ } catch (e) {
185
+ // Ignore sync failure as it's non-critical
186
+ }
187
+ }
188
+
189
+ const trackCheckoutAnalytics = async (cart: HttpTypes.StoreCart) => {
190
+ try {
191
+ const { cartToEcommercePayload, trackInitiateCheckoutFromCart } = await import(
192
+ "medusa-storefront-analytics"
193
+ )
194
+ const checkoutPayload = cartToEcommercePayload(cart)
195
+ if (checkoutPayload) {
196
+ trackInitiateCheckoutFromCart(checkoutPayload, cart?.id)
197
+ }
198
+ } catch {
199
+ // Analytics must never block checkout navigation.
200
+ }
201
+ }
202
+
203
+ const trackPaymentMethodClick = async (paymentType: "cod" | "razorpay") => {
204
+ try {
205
+ const { cartToEcommercePayload, trackAddPaymentInfo } = await import(
206
+ "medusa-storefront-analytics"
207
+ )
208
+ const payload = cartToEcommercePayload(totals as HttpTypes.StoreCart)
209
+ if (payload) {
210
+ trackAddPaymentInfo(payload, { payment_type: paymentType })
211
+ }
212
+ } catch {
213
+ // Analytics must never block payment.
214
+ }
215
+ }
216
+
217
+ const handleProceedToCheckout = async () => {
218
+ if (isRedirecting) return
219
+
220
+ const countryCode = pathname.split("/")[1] || "in"
221
+ const cart = totals as HttpTypes.StoreCart
222
+ const savedAddresses = customer?.addresses ?? []
223
+ const checkoutStep = savedAddresses.length > 0 ? "payment" : "address"
224
+
225
+ setIsRedirecting(true)
226
+
227
+ try {
228
+ await trackCheckoutAnalytics(cart)
229
+
230
+ if (savedAddresses.length > 0) {
231
+ const defaultAddress =
232
+ savedAddresses.find(
233
+ (a) =>
234
+ a.is_default_shipping ||
235
+ a.metadata?.is_default === "true" ||
236
+ a.metadata?.is_default === true
237
+ ) || savedAddresses[0]
238
+
239
+ if (defaultAddress) {
240
+ const formData = new FormData()
241
+ formData.append("shipping_address.first_name", defaultAddress.first_name || "")
242
+ formData.append("shipping_address.last_name", defaultAddress.last_name || "")
243
+ formData.append("shipping_address.address_1", defaultAddress.address_1 || "")
244
+ formData.append("shipping_address.address_2", defaultAddress.address_2 || "")
245
+ formData.append("shipping_address.company", defaultAddress.company || "")
246
+ formData.append("shipping_address.postal_code", defaultAddress.postal_code || "")
247
+ formData.append("shipping_address.city", defaultAddress.city || "")
248
+ formData.append("shipping_address.country_code", defaultAddress.country_code || "")
249
+ formData.append("shipping_address.province", defaultAddress.province || "")
250
+ formData.append("shipping_address.phone", defaultAddress.phone || "")
251
+ formData.append("email", customer?.email || "")
252
+ formData.append("same_as_billing", "on")
253
+
254
+ const { setAddresses } = await import("medusa-storefront-data/cart")
255
+ await setAddresses(null, formData)
256
+ }
257
+ }
258
+
259
+ if (isOnCartPage) {
260
+ router.push(`/${countryCode}/checkout?step=${checkoutStep}`)
261
+ }
262
+ } catch (error) {
263
+ console.error("Proceed to checkout failed:", error)
264
+ if (isOnCartPage) {
265
+ router.push(`/${countryCode}/checkout?step=address`)
266
+ }
267
+ } finally {
268
+ setIsRedirecting(false)
269
+ }
270
+ }
271
+
272
+ return (
273
+ <div className="bg-[#FFFAFE] rounded-lg p-3 min-[550px]:p-4 sm:p-5 md:p-6 min-[1023px]:p-4 min-[1150px]:p-5 min-[1360px]:p-6 border shadow-sm w-full" style={{ borderColor: '#C0C0C0' }}>
274
+ <ProcessingOverlay isOpen={showOverlay} variant={processingVariant} />
275
+ <h3 className="text-sm min-[550px]:text-base sm:text-lg min-[1023px]:text-base min-[1150px]:text-lg min-[1360px]:text-lg font-bold text-gray-900 mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
276
+ PRICE SUMMARY ({itemCount} {itemCount === 1 ? "ITEM" : "ITEMS"})
277
+ </h3>
278
+
279
+ <div className="flex flex-col gap-y-2 min-[550px]:gap-y-2.5 sm:gap-y-3 min-[1023px]:gap-y-2 min-[1150px]:gap-y-3 min-[1360px]:gap-y-3 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-gray-900">
280
+ <div className="flex items-center justify-between">
281
+ <span>Total MRP</span>
282
+ <span data-testid="cart-mrp" data-value={totalMRP}>
283
+ {convertToLocale({ amount: totalMRP, currency_code })}
284
+ </span>
285
+ </div>
286
+
287
+ {calculatedDiscount > 0 && (
288
+ <div className="flex items-center justify-between">
289
+ <span>Discount</span>
290
+ <span
291
+ className="text-gray-900"
292
+ data-testid="cart-discount"
293
+ data-value={calculatedDiscount}
294
+ >
295
+ {convertToLocale({
296
+ amount: -calculatedDiscount,
297
+ currency_code,
298
+ })}
299
+ </span>
300
+ </div>
301
+ )}
302
+
303
+ <div className="flex items-center justify-between">
304
+ <span>GST</span>
305
+ <span data-testid="cart-taxes" data-value={gstAmount}>
306
+ {convertToLocale({ amount: gstAmount, currency_code })}
307
+ </span>
308
+ </div>
309
+
310
+ <div className="flex items-center justify-between">
311
+ <span>Shipping</span>
312
+ <span data-testid="cart-shipping" data-value={shippingAmount} className="flex items-center gap-x-2">
313
+ {isAutoSettingShipping && (
314
+ <span className="w-4 h-4 border-2 border-[#8B5AB1] border-t-transparent rounded-full animate-spin"></span>
315
+ )}
316
+ {convertToLocale({ amount: shippingAmount, currency_code })}
317
+ </span>
318
+ </div>
319
+ </div>
320
+
321
+ <div className="h-px w-full border-b border-gray-200 my-3 min-[550px]:my-4" />
322
+
323
+ <div className="flex items-center justify-between text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base font-bold text-gray-900 mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
324
+ <span>Total</span>
325
+ <span
326
+ className="text-sm min-[550px]:text-base sm:text-lg min-[1023px]:text-base min-[1150px]:text-lg min-[1360px]:text-lg"
327
+ data-testid="cart-total"
328
+ data-value={finalTotal}
329
+ >
330
+ {convertToLocale({ amount: finalTotal, currency_code })}
331
+ </span>
332
+ </div>
333
+
334
+ {calculatedDiscount > 0 && (
335
+ <>
336
+ <div className="h-px w-full border-b border-dashed border-gray-300 my-2.5 min-[550px]:my-3 sm:my-4 min-[1023px]:my-3 min-[1150px]:my-4 min-[1360px]:my-4" />
337
+ <div className="text-center">
338
+ <p className="text-green-600 font-medium text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base">
339
+ 🎉 Yayy!!! You've saved {convertToLocale({ amount: calculatedDiscount, currency_code })}
340
+ </p>
341
+ </div>
342
+ </>
343
+ )}
344
+
345
+ {/* No manual continue button needed as we are in one-step mode */}
346
+ {isOnCartPage && (
347
+ <button
348
+ onClick={handleProceedToCheckout}
349
+ disabled={isRedirecting}
350
+ data-ga-event="proceed_to_checkout_click"
351
+ data-meta-event="InitiateCheckout"
352
+ data-meta-action="proceed_to_checkout"
353
+ data-ga-label={convertToLocale({ amount: finalTotal, currency_code })}
354
+ className="w-full h-10 min-[550px]:h-11 sm:h-12 min-[1023px]:h-11 min-[1150px]:h-12 min-[1360px]:h-12 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium transition-colors duration-200 hover:opacity-90 mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 disabled:opacity-50"
355
+ style={{
356
+ backgroundColor: '#8B5AB1',
357
+ borderRadius: '30px'
358
+ }}
359
+ data-testid="checkout-button"
360
+ >
361
+ {isRedirecting ? "Loading..." : "Proceed to Checkout"}
362
+ </button>
363
+ )}
364
+ {!isOnCartPage && (
365
+ <div className="w-full">
366
+ {activeStep === "delivery" || activeStep === "payment" || !((totals as any)?.shipping_address?.address_1) || pathname.includes("/checkout") ? (
367
+ <div className="flex flex-col gap-3 mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6">
368
+ {!isRedirecting ? (
369
+ (() => {
370
+ const isCheckoutReady =
371
+ ((totals as any).shipping_methods?.length > 0) &&
372
+ !!((totals as any).shipping_address?.address_1) &&
373
+ ((totals as any).shipping_address?.postal_code?.length >= 6);
374
+ const isPaymentDisabled = isAutoSettingShipping || !isCheckoutReady;
375
+
376
+ return (
377
+ <>
378
+ <button
379
+ onClick={async () => {
380
+ trackPaymentMethodClick("cod")
381
+ setProcessingVariant("order")
382
+ setShowOverlay(true)
383
+ setIsRedirecting(true)
384
+ try {
385
+ await syncCheckoutAddressFromDom({
386
+ email: (totals as any)?.email,
387
+ shipping_address: (totals as any)?.shipping_address,
388
+ })
389
+ await syncLastEtdToCart()
390
+ const { initiatePaymentSession, placeOrder } = await import("medusa-storefront-data/cart")
391
+ await initiatePaymentSession(totals as any, { provider_id: "pp_system_default" })
392
+ await placeOrder()
393
+ } catch (err: any) {
394
+ if (err.message === "NEXT_REDIRECT") {
395
+ throw err // Next.js needs this error to perform the redirect
396
+ }
397
+ setIsRedirecting(false)
398
+ setShowOverlay(false)
399
+ alert("Oops! Something went wrong with Cash on Delivery. " + err.message)
400
+ }
401
+ }}
402
+ disabled={isPaymentDisabled}
403
+ data-ga-event="cod_payment_click"
404
+ data-meta-event="AddPaymentInfo"
405
+ data-meta-action="select_payment_method"
406
+ data-meta-payment-type="cod"
407
+ className={clx(
408
+ "w-full py-2.5 min-[550px]:py-3 text-xs min-[550px]:text-sm sm:text-base text-gray-900 bg-white border-2 font-bold rounded-lg transition-colors border-[#8B5AB1]",
409
+ {
410
+ "hover:bg-purple-50": !isPaymentDisabled,
411
+ "opacity-50 cursor-not-allowed": isPaymentDisabled
412
+ }
413
+ )}
414
+ style={{ borderRadius: '30px' }}
415
+ >
416
+ Cash on Delivery
417
+ </button>
418
+ <button
419
+ onClick={async () => {
420
+ trackPaymentMethodClick("razorpay")
421
+ setProcessingVariant("payment")
422
+ setShowOverlay(true)
423
+ setIsRedirecting(true)
424
+ try {
425
+ const synced = await syncCheckoutAddressFromDom({
426
+ email: (totals as any)?.email,
427
+ shipping_address: (totals as any)?.shipping_address,
428
+ })
429
+ await syncLastEtdToCart()
430
+ const { initiatePaymentSession, placeOrder } = await import("medusa-storefront-data/cart")
431
+ const updatedCart: any = await initiatePaymentSession(totals as any, { provider_id: "pp_razorpay_razorpay" }).catch(() => initiatePaymentSession(totals as any, { provider_id: "razorpay" }))
432
+
433
+ const razorpaySession = updatedCart?.payment_collection?.payment_sessions?.find(
434
+ (s: any) => s.provider_id.includes("razorpay")
435
+ )
436
+ if (!razorpaySession?.data?.id) throw new Error("Could not initialize Razorpay")
437
+
438
+ const paymentEmail = synced.email || (updatedCart as any)?.email || (totals as any)?.email
439
+ const paymentPhone =
440
+ synced.shipping_address.phone ||
441
+ (updatedCart as any)?.shipping_address?.phone ||
442
+ (totals as any)?.shipping_address?.phone
443
+
444
+ // Load loadRazorpay dynamically so it resolves window.Razorpay
445
+ const useRazorpayMod = await import("react-razorpay")
446
+ const useRazorpay = useRazorpayMod.useRazorpay || useRazorpayMod.default?.useRazorpay || (globalThis as any).useRazorpay
447
+
448
+ // We must load via script if hook isn't directly invocable in this async context
449
+ // The safest pure JS way:
450
+ const script = document.createElement("script")
451
+ script.src = "https://checkout.razorpay.com/v1/checkout.js"
452
+ script.onload = () => {
453
+ const options = {
454
+ key: process.env.NEXT_PUBLIC_RAZORPAY_KEY ?? "",
455
+ amount: razorpaySession.amount,
456
+ order_id: razorpaySession.data.id,
457
+ currency: currency_code.toUpperCase(),
458
+ name: company.name,
459
+ description: `Secure checkout for your order`,
460
+ handler: async function (response: any) {
461
+ await placeOrder().catch((err: any) => {
462
+ if (err.message === "NEXT_REDIRECT") throw err;
463
+ alert("Error placing order: " + err.message);
464
+ })
465
+ },
466
+ prefill: {
467
+ name: `${(totals as any).billing_address?.first_name || synced.shipping_address.first_name || ""} ${(totals as any).billing_address?.last_name || synced.shipping_address.last_name || ""}`.trim(),
468
+ email: paymentEmail,
469
+ contact: paymentPhone || undefined,
470
+ },
471
+ theme: {
472
+ color: "#8B5AB1"
473
+ },
474
+ modal: {
475
+ ondismiss: () => {
476
+ setIsRedirecting(false)
477
+ setShowOverlay(false)
478
+ }
479
+ }
480
+ }
481
+ const rzp = new (window as any).Razorpay(options)
482
+ rzp.on("payment.failed", function (response: any) {
483
+ setIsRedirecting(false)
484
+ setShowOverlay(false)
485
+ })
486
+ rzp.open()
487
+ }
488
+ document.body.appendChild(script)
489
+
490
+ } catch (err: any) {
491
+ setIsRedirecting(false)
492
+ setShowOverlay(false)
493
+ alert("Oops! Something went wrong with Razorpay. " + err.message)
494
+ }
495
+ }}
496
+ disabled={isPaymentDisabled}
497
+ data-ga-event="razorpay_payment_click"
498
+ data-meta-event="AddPaymentInfo"
499
+ data-meta-action="select_payment_method"
500
+ data-meta-payment-type="razorpay"
501
+ className={clx(
502
+ "w-full py-2.5 min-[550px]:py-3 text-xs min-[550px]:text-sm sm:text-base text-white font-bold rounded-lg transition-colors bg-[#8B5AB1]",
503
+ {
504
+ "hover:opacity-90": !isPaymentDisabled,
505
+ "opacity-50 cursor-not-allowed": isPaymentDisabled
506
+ }
507
+ )}
508
+ style={{ borderRadius: '30px' }}
509
+ >
510
+ Pay with Razorpay
511
+ </button>
512
+ </>
513
+ ); })()
514
+ ) : (
515
+ <button
516
+ disabled
517
+ className="w-full py-2.5 min-[550px]:py-3 text-xs min-[550px]:text-sm sm:text-base text-white font-bold rounded-lg bg-gray-400 opacity-50 cursor-not-allowed"
518
+ style={{ borderRadius: '30px' }}
519
+ >
520
+ Processing...
521
+ </button>
522
+ )}
523
+ </div>
524
+ ) : activeStep === "address" ? (
525
+ <button
526
+ onClick={() => {
527
+ window.dispatchEvent(new CustomEvent('trigger-checkout-step'))
528
+ }}
529
+ className="w-full py-2.5 min-[550px]:py-3 sm:py-4 min-[1023px]:py-3 min-[1150px]:py-4 min-[1360px]:py-4 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 rounded-lg transition-colors duration-200 hover:opacity-90"
530
+ style={{
531
+ backgroundColor: '#8B5AB1',
532
+ borderRadius: '30px'
533
+ }}
534
+ data-testid="checkout-button-address"
535
+ >
536
+ Continue to delivery
537
+ </button>
538
+ ) : (
539
+ <button
540
+ onClick={handleProceedToCheckout}
541
+ disabled={isRedirecting}
542
+ data-ga-event="proceed_to_checkout_click"
543
+ data-meta-event="InitiateCheckout"
544
+ data-meta-action="proceed_to_checkout"
545
+ data-ga-label={convertToLocale({ amount: finalTotal, currency_code })}
546
+ className="w-full h-10 min-[550px]:h-11 sm:h-12 min-[1023px]:h-11 min-[1150px]:h-12 min-[1360px]:h-12 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium transition-colors duration-200 hover:opacity-90 mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 disabled:opacity-50"
547
+ style={{
548
+ backgroundColor: '#8B5AB1',
549
+ borderRadius: '30px'
550
+ }}
551
+ data-testid="checkout-button"
552
+ >
553
+ {isRedirecting ? "Loading..." : "Proceed to Checkout"}
554
+ </button>
555
+ )}
556
+ </div>
557
+ )}
558
+ </div>
559
+ )
560
+ }
561
+
562
+ export default CartTotals
@@ -0,0 +1,98 @@
1
+ import { Label } from "@medusajs/ui"
2
+ import React from "react"
3
+ import Color from "color"
4
+
5
+ type CheckboxProps = {
6
+ checked?: boolean
7
+ onChange?: () => void
8
+ label: string
9
+ name?: string
10
+ color?: string
11
+ hex?: string
12
+ 'data-testid'?: string
13
+ 'data-ga-event'?: string
14
+ 'data-ga-label'?: string
15
+ }
16
+
17
+ const CheckboxWithLabel: React.FC<CheckboxProps> = ({
18
+ checked = true,
19
+ onChange,
20
+ label,
21
+ name,
22
+ color,
23
+ hex,
24
+ 'data-testid': dataTestId,
25
+ 'data-ga-event': dataGaEvent,
26
+ 'data-ga-label': dataGaLabel
27
+ }) => {
28
+ const colorHex = React.useMemo(() => {
29
+ if (hex) return hex
30
+ if (!color) return null
31
+
32
+ try {
33
+ // Try parsing the original color name (handles things like "darkgreen")
34
+ return Color(color.toLowerCase().replace(/\s+/g, '')).hex()
35
+ } catch (e) {
36
+ try {
37
+ // Try parsing without modification (handles things like "rgb(0,0,0)")
38
+ return Color(color).hex()
39
+ } catch (e2) {
40
+ // Final fallback to the color string itself (might be a valid CSS name or hex already)
41
+ return color
42
+ }
43
+ }
44
+ }, [hex, color])
45
+
46
+ return (
47
+ <div className="flex items-center space-x-2 px-1 py-0.5">
48
+ <button
49
+ type="button"
50
+ role="checkbox"
51
+ aria-checked={checked}
52
+ onClick={onChange}
53
+ name={name}
54
+ data-testid={dataTestId}
55
+ data-ga-event={dataGaEvent}
56
+ data-ga-label={dataGaLabel}
57
+ className="w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all focus:outline-none focus:ring-2 focus:ring-offset-2"
58
+ style={checked ? {
59
+ backgroundColor: '#8B5AB1',
60
+ borderColor: '#8B5AB1'
61
+ } : {
62
+ backgroundColor: 'white',
63
+ borderColor: '#C0C0C0'
64
+ }}
65
+ >
66
+ {checked && (
67
+ <svg
68
+ className="w-3 h-3 text-white"
69
+ fill="none"
70
+ strokeLinecap="round"
71
+ strokeLinejoin="round"
72
+ strokeWidth="2"
73
+ viewBox="0 0 24 24"
74
+ stroke="currentColor"
75
+ >
76
+ <path d="M5 13l4 4L19 7"></path>
77
+ </svg>
78
+ )}
79
+ </button>
80
+ <Label
81
+ htmlFor={name || "checkbox"}
82
+ className="!transform-none !txt-medium cursor-pointer flex items-center gap-2"
83
+ size="large"
84
+ onClick={onChange}
85
+ >
86
+ {colorHex && (
87
+ <div
88
+ className="w-4 h-4 rounded-full border border-gray-100 shadow-sm"
89
+ style={{ backgroundColor: colorHex }}
90
+ />
91
+ )}
92
+ {label}
93
+ </Label>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ export default CheckboxWithLabel