@pradip1995/segment-jewelry-checkout-form 0.1.7 → 0.1.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/segment-jewelry-checkout-form",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -40,8 +40,8 @@ export default function CheckoutBagItems({
40
40
  src={thumbnail}
41
41
  alt={line.title}
42
42
  fill
43
- className="object-cover"
44
- sizes="88px"
43
+ className="jewelry-checkout-bag__img"
44
+ sizes="120px"
45
45
  unoptimized
46
46
  />
47
47
  ) : (
@@ -50,7 +50,21 @@ export default function CheckoutBagItems({
50
50
  </div>
51
51
  <div className="jewelry-checkout-bag__meta">
52
52
  <p className="jewelry-checkout-bag__name">{line.title}</p>
53
- {line.variantLabel ? (
53
+ {line.variantOptions.length > 0 ? (
54
+ <ul className="jewelry-checkout-bag__variants">
55
+ {line.variantOptions.map((option) => (
56
+ <li
57
+ key={`${item.id}-${option.label ?? "option"}-${option.value}`}
58
+ className="jewelry-checkout-bag__variant-chip"
59
+ >
60
+ {option.label ? (
61
+ <span className="jewelry-checkout-bag__variant-label">{option.label}</span>
62
+ ) : null}
63
+ <span>{option.value}</span>
64
+ </li>
65
+ ))}
66
+ </ul>
67
+ ) : line.variantLabel ? (
54
68
  <p className="jewelry-checkout-bag__variant">{line.variantLabel}</p>
55
69
  ) : null}
56
70
  <p className="jewelry-checkout-bag__qty">
@@ -3,9 +3,11 @@ import { describe, it } from "node:test"
3
3
  import {
4
4
  checkoutItemCount,
5
5
  checkoutLineView,
6
+ checkoutVariantOptions,
6
7
  isPlaceholderVariant,
7
8
  type CheckoutLineItemInput,
8
9
  type CheckoutLineView,
10
+ type CheckoutVariantOption,
9
11
  } from "./checkout-line.ts"
10
12
 
11
13
  const LINE_CASES: Array<{
@@ -26,6 +28,7 @@ const LINE_CASES: Array<{
26
28
  id: "li_1",
27
29
  title: "Pendants demo 3",
28
30
  variantLabel: null,
31
+ variantOptions: [],
29
32
  quantity: 1,
30
33
  quantityLabel: "Qty 1",
31
34
  unitPrice: 869,
@@ -44,6 +47,7 @@ const LINE_CASES: Array<{
44
47
  id: "li_2",
45
48
  title: "Gold hoop",
46
49
  variantLabel: null,
50
+ variantOptions: [],
47
51
  quantity: 3,
48
52
  quantityLabel: "Qty 3",
49
53
  unitPrice: 500,
@@ -65,6 +69,10 @@ const LINE_CASES: Array<{
65
69
  id: "li_3",
66
70
  title: "Emerald ring",
67
71
  variantLabel: "18K Gold / 6",
72
+ variantOptions: [
73
+ { label: null, value: "18K Gold" },
74
+ { label: null, value: "6" },
75
+ ],
68
76
  quantity: 2,
69
77
  quantityLabel: "Qty 2",
70
78
  unitPrice: 1200,
@@ -85,6 +93,7 @@ const LINE_CASES: Array<{
85
93
  id: "li_4",
86
94
  title: "Pendants demo 2",
87
95
  variantLabel: null,
96
+ variantOptions: [],
88
97
  quantity: 1,
89
98
  quantityLabel: "Qty 1",
90
99
  unitPrice: 846,
@@ -98,6 +107,7 @@ const LINE_CASES: Array<{
98
107
  id: "",
99
108
  title: "Product",
100
109
  variantLabel: null,
110
+ variantOptions: [],
101
111
  quantity: 1,
102
112
  quantityLabel: "Qty 1",
103
113
  unitPrice: 0,
@@ -138,6 +148,51 @@ describe("checkoutItemCount", () => {
138
148
  }
139
149
  })
140
150
 
151
+ const VARIANT_OPTION_CASES: Array<{
152
+ name: string
153
+ item: CheckoutLineItemInput
154
+ want: CheckoutVariantOption[]
155
+ }> = [
156
+ {
157
+ name: "returns every structured option and drops placeholder values",
158
+ item: {
159
+ variant: {
160
+ title: "22K Yellow Gold / 16 inch",
161
+ options: [
162
+ { value: "22K Yellow Gold", option: { title: "Metal" } },
163
+ { value: "16 inch", option: { title: "Length" } },
164
+ { value: "Default", option: { title: "Default Option" } },
165
+ ],
166
+ },
167
+ },
168
+ want: [
169
+ { label: "Metal", value: "22K Yellow Gold" },
170
+ { label: "Length", value: "16 inch" },
171
+ ],
172
+ },
173
+ {
174
+ name: "splits a combined variant title when options are missing",
175
+ item: { variant: { title: "18K Gold / 6" } },
176
+ want: [
177
+ { label: null, value: "18K Gold" },
178
+ { label: null, value: "6" },
179
+ ],
180
+ },
181
+ {
182
+ name: "returns no chips for a default variant",
183
+ item: { variant: { title: "Default variant" } },
184
+ want: [],
185
+ },
186
+ ]
187
+
188
+ describe("checkoutVariantOptions", () => {
189
+ for (const row of VARIANT_OPTION_CASES) {
190
+ it(row.name, () => {
191
+ assert.deepEqual(checkoutVariantOptions(row.item), row.want)
192
+ })
193
+ }
194
+ })
195
+
141
196
  describe("isPlaceholderVariant", () => {
142
197
  for (const row of VARIANT_CASES) {
143
198
  it(row.name, () => {
@@ -1,3 +1,13 @@
1
+ export type CheckoutVariantOption = {
2
+ label: string | null
3
+ value: string
4
+ }
5
+
6
+ type CheckoutVariantOptionInput = {
7
+ value?: string | null
8
+ option?: { title?: string | null } | null
9
+ }
10
+
1
11
  export type CheckoutLineItemInput = {
2
12
  id?: string | null
3
13
  title?: string | null
@@ -5,13 +15,17 @@ export type CheckoutLineItemInput = {
5
15
  quantity?: number | null
6
16
  unit_price?: number | null
7
17
  total?: number | null
8
- variant?: { title?: string | null } | null
18
+ variant?: {
19
+ title?: string | null
20
+ options?: CheckoutVariantOptionInput[] | null
21
+ } | null
9
22
  }
10
23
 
11
24
  export type CheckoutLineView = {
12
25
  id: string
13
26
  title: string
14
27
  variantLabel: string | null
28
+ variantOptions: CheckoutVariantOption[]
15
29
  quantity: number
16
30
  quantityLabel: string
17
31
  unitPrice: number
@@ -28,12 +42,45 @@ export function isPlaceholderVariant(title?: string | null): boolean {
28
42
  )
29
43
  }
30
44
 
45
+ function cleanOptionLabel(title?: string | null): string | null {
46
+ const trimmed = title?.trim()
47
+ if (!trimmed || isPlaceholderVariant(trimmed)) return null
48
+ return trimmed
49
+ }
50
+
51
+ function optionFromParts(
52
+ value?: string | null,
53
+ label?: string | null
54
+ ): CheckoutVariantOption | null {
55
+ const trimmed = value?.trim()
56
+ if (!trimmed || isPlaceholderVariant(trimmed)) return null
57
+ return { label: cleanOptionLabel(label), value: trimmed }
58
+ }
59
+
60
+ /** Every real variant option on the line, including values split from a combined title. */
61
+ export function checkoutVariantOptions(item: CheckoutLineItemInput): CheckoutVariantOption[] {
62
+ const structured = (item.variant?.options || [])
63
+ .map((option) => optionFromParts(option.value, option.option?.title))
64
+ .filter((option): option is CheckoutVariantOption => Boolean(option))
65
+
66
+ if (structured.length > 0) return structured
67
+
68
+ const title = item.variant?.title
69
+ if (!title || isPlaceholderVariant(title)) return []
70
+
71
+ return title
72
+ .split("/")
73
+ .map((part) => optionFromParts(part))
74
+ .filter((option): option is CheckoutVariantOption => Boolean(option))
75
+ }
76
+
31
77
  export function checkoutLineView(item: CheckoutLineItemInput): CheckoutLineView {
32
78
  const quantity = Math.max(1, Number(item.quantity) || 1)
33
79
  const unitPrice = Number(item.unit_price) || 0
34
80
  const lineTotal =
35
81
  typeof item.total === "number" ? item.total : unitPrice * quantity
36
82
  const variantTitle = item.variant?.title
83
+ const variantOptions = checkoutVariantOptions(item)
37
84
  const variantLabel =
38
85
  variantTitle && !isPlaceholderVariant(variantTitle) ? variantTitle : null
39
86
 
@@ -41,6 +88,7 @@ export function checkoutLineView(item: CheckoutLineItemInput): CheckoutLineView
41
88
  id: item.id || "",
42
89
  title: (item.product_title || item.title || "Product").trim() || "Product",
43
90
  variantLabel,
91
+ variantOptions,
44
92
  quantity,
45
93
  quantityLabel: `Qty ${quantity}`,
46
94
  unitPrice,
@@ -13,6 +13,9 @@ import {
13
13
  } from "@pradip1995/commerce-core/util/payment-providers"
14
14
  import { isNextRedirect } from "@pradip1995/segment-primitives/is-next-redirect"
15
15
  import type { HttpTypes } from "@medusajs/types"
16
+ import {
17
+ isOneClickUpi,
18
+ } from "./upi-intent"
16
19
 
17
20
  type CartLike = HttpTypes.StoreCart & {
18
21
  payment_collection?: {
@@ -176,6 +179,33 @@ async function openCashfreeCheckout(options: {
176
179
  // Redirect checkout: Cashfree navigates away; order is finalized on return.
177
180
  }
178
181
 
182
+ export async function startUpiIntentCheckout(options: {
183
+ cart: CartLike
184
+ providerId: string
185
+ }): Promise<{ cart: CartLike; session: HttpTypes.StorePaymentSession }> {
186
+ const { cart, providerId } = options
187
+ if (!isOneClickUpi(providerId)) {
188
+ throw new Error(`Not a 1-click UPI provider: ${providerId}`)
189
+ }
190
+
191
+ // Must use the exact UPI provider id — commerce-core init attempts collapse to hosted Razorpay.
192
+ await initiatePaymentSession(cart, { provider_id: providerId })
193
+ const updatedCart = await retrieveCartWithPayments(cart.id)
194
+ if (!updatedCart) {
195
+ throw new Error("Cart not found after UPI payment initialization")
196
+ }
197
+
198
+ const session = updatedCart.payment_collection?.payment_sessions?.find(
199
+ (item) => item.provider_id === providerId
200
+ ) as HttpTypes.StorePaymentSession | undefined
201
+
202
+ if (!session) {
203
+ throw new Error("Could not initialize 1-click UPI")
204
+ }
205
+
206
+ return { cart: updatedCart, session }
207
+ }
208
+
179
209
  export async function openHostedCheckout(options: {
180
210
  cart: CartLike
181
211
  providerId: string
@@ -183,6 +213,9 @@ export async function openHostedCheckout(options: {
183
213
  onDismiss?: () => void
184
214
  }): Promise<void> {
185
215
  const { cart, providerId, shopName, onDismiss } = options
216
+ if (isOneClickUpi(providerId)) {
217
+ throw new Error("Use startUpiIntentCheckout for 1-click UPI")
218
+ }
186
219
  const kind = getPaymentGatewayKind(providerId)
187
220
 
188
221
  const updatedCart = await initiatePaymentSessionWithFallback(cart, providerId)
@@ -225,8 +258,13 @@ export function partitionPaymentProviders(
225
258
  ) {
226
259
  const manual: Array<{ id: string }> = []
227
260
  const hosted: Array<{ id: string }> = []
261
+ const upi: Array<{ id: string }> = []
228
262
 
229
263
  for (const provider of providers ?? []) {
264
+ if (isOneClickUpi(provider.id)) {
265
+ upi.push(provider)
266
+ continue
267
+ }
230
268
  const kind = getPaymentGatewayKind(provider.id)
231
269
  if (kind === "manual") {
232
270
  manual.push(provider)
@@ -235,7 +273,14 @@ export function partitionPaymentProviders(
235
273
  }
236
274
  }
237
275
 
238
- return { manual, hosted }
276
+ return { manual, hosted, upi }
277
+ }
278
+
279
+ export function pickUpiPaymentProvider(
280
+ providers: Array<{ id: string }> | null | undefined
281
+ ): string | null {
282
+ const { upi } = partitionPaymentProviders(providers)
283
+ return upi.length > 0 ? upi[0].id : null
239
284
  }
240
285
 
241
286
  export function pickHostedPaymentProvider(
@@ -245,7 +290,10 @@ export function pickHostedPaymentProvider(
245
290
  const preferred = process.env.NEXT_PUBLIC_PAYMENT_PROVIDER?.trim().toLowerCase()
246
291
 
247
292
  if (preferred === "razorpay") {
248
- const razorpay = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "razorpay")
293
+ const razorpay = hosted.find(
294
+ (provider) =>
295
+ getPaymentGatewayKind(provider.id) === "razorpay" && !isOneClickUpi(provider.id)
296
+ )
249
297
  if (razorpay) return razorpay.id
250
298
  }
251
299
 
@@ -257,7 +305,10 @@ export function pickHostedPaymentProvider(
257
305
  const cashfree = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "cashfree")
258
306
  if (cashfree) return cashfree.id
259
307
 
260
- const razorpay = hosted.find((provider) => getPaymentGatewayKind(provider.id) === "razorpay")
308
+ const razorpay = hosted.find(
309
+ (provider) =>
310
+ getPaymentGatewayKind(provider.id) === "razorpay" && !isOneClickUpi(provider.id)
311
+ )
261
312
  if (razorpay) return razorpay.id
262
313
 
263
314
  if (hosted.length > 0) return hosted[0].id
@@ -18,20 +18,20 @@
18
18
  }
19
19
 
20
20
  .jewelry-checkout-payment__btn--cod:hover:not(:disabled) {
21
- border-color: #3b1f4e !important;
22
- color: #3b1f4e !important;
21
+ border-color: var(--color-brand-accent, #13264d) !important;
22
+ color: var(--color-brand-accent, #13264d) !important;
23
23
  background: #ffffff !important;
24
24
  }
25
25
 
26
26
  .jewelry-checkout-payment__btn--online {
27
- background: #3b1f4e !important;
28
- border: 1px solid #3b1f4e !important;
27
+ background: var(--color-brand-accent, #13264d) !important;
28
+ border: 1px solid var(--color-brand-accent, #13264d) !important;
29
29
  color: #ffffff !important;
30
30
  }
31
31
 
32
32
  .jewelry-checkout-payment__btn--online:hover:not(:disabled) {
33
- background: #55306c !important;
34
- border-color: #55306c !important;
33
+ background: var(--color-brand-accent-hover, #1c3a70) !important;
34
+ border-color: var(--color-brand-accent-hover, #1c3a70) !important;
35
35
  color: #ffffff !important;
36
36
  }
37
37
 
@@ -83,7 +83,7 @@
83
83
  top: 0.45rem;
84
84
  transform: translateY(0);
85
85
  font-size: 0.6875rem;
86
- color: var(--checkout-ink, #3b1f4e);
86
+ color: var(--checkout-ink, var(--color-brand-accent, #13264d));
87
87
  }
88
88
 
89
89
  .jewelry-checkout-address__check {
@@ -99,7 +99,7 @@
99
99
  .jewelry-checkout-address__check input {
100
100
  width: 1rem;
101
101
  height: 1rem;
102
- accent-color: #3b1f4e;
102
+ accent-color: var(--color-brand-accent, #13264d);
103
103
  }
104
104
 
105
105
  .jewelry-checkout-address__head {
@@ -128,7 +128,7 @@
128
128
  width: 3px;
129
129
  height: 0.95em;
130
130
  border-radius: 1px;
131
- background: #3b1f4e;
131
+ background: var(--color-brand-accent, #13264d);
132
132
  }
133
133
 
134
134
  .jewelry-checkout-address__actions {
@@ -156,14 +156,14 @@
156
156
  }
157
157
 
158
158
  .jewelry-checkout-address__btn--primary {
159
- background: #3b1f4e;
160
- border: 1px solid #3b1f4e;
159
+ background: var(--color-brand-accent, #13264d);
160
+ border: 1px solid var(--color-brand-accent, #13264d);
161
161
  color: #ffffff;
162
162
  }
163
163
 
164
164
  .jewelry-checkout-address__btn--primary:hover:not(:disabled) {
165
- background: #55306c;
166
- border-color: #55306c;
165
+ background: var(--color-brand-accent-hover, #1c3a70);
166
+ border-color: var(--color-brand-accent-hover, #1c3a70);
167
167
  }
168
168
 
169
169
  .jewelry-checkout-address__btn--outline {
@@ -174,8 +174,8 @@
174
174
 
175
175
  .jewelry-checkout-address__btn--outline:hover:not(:disabled) {
176
176
  background: #f7f3fa;
177
- border-color: #3b1f4e;
178
- color: #3b1f4e;
177
+ border-color: var(--color-brand-accent, #13264d);
178
+ color: var(--color-brand-accent, #13264d);
179
179
  }
180
180
 
181
181
  .jewelry-checkout-address__btn--block {
@@ -220,7 +220,7 @@
220
220
  .jewelry-checkout-address-card__body--selected {
221
221
  background: #faf6f1;
222
222
  border-color: #e4dcd3;
223
- box-shadow: inset 4px 0 0 #3b1f4e;
223
+ box-shadow: inset 4px 0 0 var(--color-brand-accent, #13264d);
224
224
  }
225
225
 
226
226
  .jewelry-checkout-address-card__body--pickable {
@@ -229,7 +229,7 @@
229
229
  }
230
230
 
231
231
  .jewelry-checkout-address-card__body--pickable:hover {
232
- border-color: #3b1f4e;
232
+ border-color: var(--color-brand-accent, #13264d);
233
233
  }
234
234
 
235
235
  .jewelry-checkout-address-card__radio {
@@ -240,9 +240,9 @@
240
240
  height: 1.125rem;
241
241
  margin-top: 0.2rem;
242
242
  flex-shrink: 0;
243
- border: 1.5px solid #3b1f4e;
243
+ border: 1.5px solid var(--color-brand-accent, #13264d);
244
244
  border-radius: 999px;
245
- background: #3b1f4e;
245
+ background: var(--color-brand-accent, #13264d);
246
246
  box-sizing: border-box;
247
247
  }
248
248
 
@@ -283,7 +283,7 @@
283
283
  font-family: var(--font-sans, inherit);
284
284
  font-size: 0.9375rem;
285
285
  font-weight: 700;
286
- color: #3b1f4e;
286
+ color: var(--color-brand-accent, #13264d);
287
287
  }
288
288
 
289
289
  .jewelry-checkout-address-card__tag {
@@ -300,8 +300,8 @@
300
300
  }
301
301
 
302
302
  .jewelry-checkout-address-card__tag--default {
303
- background: #3b1f4e;
304
- border-color: #3b1f4e;
303
+ background: var(--color-brand-accent, #13264d);
304
+ border-color: var(--color-brand-accent, #13264d);
305
305
  color: #ffffff;
306
306
  }
307
307
 
@@ -365,8 +365,8 @@
365
365
 
366
366
  .jewelry-checkout-address-card__edit:hover {
367
367
  background: #f7f3fa;
368
- border-color: #3b1f4e;
369
- color: #3b1f4e;
368
+ border-color: var(--color-brand-accent, #13264d);
369
+ color: var(--color-brand-accent, #13264d);
370
370
  }
371
371
 
372
372
  .jewelry-checkout-address-empty {
@@ -489,7 +489,7 @@
489
489
  }
490
490
 
491
491
  .jewelry-checkout-modal__option:hover:not(:disabled) .jewelry-checkout-address-card__body {
492
- border-color: #3b1f4e;
492
+ border-color: var(--color-brand-accent, #13264d);
493
493
  }
494
494
 
495
495
  .jewelry-checkout-modal__option:disabled {
@@ -582,7 +582,7 @@
582
582
 
583
583
  .jewelry-checkout-modal__input:focus {
584
584
  outline: none;
585
- border-color: #3b1f4e;
585
+ border-color: var(--color-brand-accent, #13264d);
586
586
  box-shadow: 0 0 0 3px rgba(59, 31, 78, 0.1);
587
587
  }
588
588
 
@@ -598,7 +598,7 @@
598
598
  .jewelry-checkout-modal__check input {
599
599
  width: 1rem;
600
600
  height: 1rem;
601
- accent-color: #3b1f4e;
601
+ accent-color: var(--color-brand-accent, #13264d);
602
602
  }
603
603
 
604
604
  .jewelry-checkout-modal__type {
@@ -645,8 +645,8 @@
645
645
  }
646
646
 
647
647
  .jewelry-checkout-modal__type-option input:checked + span {
648
- background: #3b1f4e;
649
- border-color: #3b1f4e;
648
+ background: var(--color-brand-accent, #13264d);
649
+ border-color: var(--color-brand-accent, #13264d);
650
650
  color: #ffffff;
651
651
  }
652
652
 
@@ -684,8 +684,8 @@
684
684
  }
685
685
 
686
686
  .jewelry-checkout-modal__save {
687
- background: #3b1f4e;
688
- border: 1px solid #3b1f4e;
687
+ background: var(--color-brand-accent, #13264d);
688
+ border: 1px solid var(--color-brand-accent, #13264d);
689
689
  color: #ffffff;
690
690
  }
691
691
 
@@ -706,8 +706,8 @@
706
706
  }
707
707
 
708
708
  .jewelry-checkout-modal__cancel:hover {
709
- border-color: #3b1f4e;
710
- color: #3b1f4e;
709
+ border-color: var(--color-brand-accent, #13264d);
710
+ color: var(--color-brand-accent, #13264d);
711
711
  }
712
712
 
713
713
  @media (max-width: 640px) {
@@ -781,7 +781,7 @@
781
781
  border-radius: 999px;
782
782
  background: #ffffff;
783
783
  border: 1px solid rgba(59, 31, 78, 0.14);
784
- color: #3b1f4e;
784
+ color: var(--color-brand-accent, #13264d);
785
785
  box-shadow: 0 6px 16px rgba(59, 31, 78, 0.08);
786
786
  }
787
787
 
@@ -792,7 +792,7 @@
792
792
  font-weight: 500;
793
793
  letter-spacing: 0.1em;
794
794
  text-transform: uppercase;
795
- color: #3b1f4e;
795
+ color: var(--color-brand-accent, #13264d);
796
796
  }
797
797
 
798
798
  .jewelry-leave-checkout__body {
@@ -832,8 +832,8 @@
832
832
 
833
833
  .jewelry-leave-checkout__btn--stay {
834
834
  background: #ffffff;
835
- border: 1px solid #3b1f4e;
836
- color: #3b1f4e;
835
+ border: 1px solid var(--color-brand-accent, #13264d);
836
+ color: var(--color-brand-accent, #13264d);
837
837
  }
838
838
 
839
839
  .jewelry-leave-checkout__btn--stay:hover {
@@ -841,18 +841,18 @@
841
841
  }
842
842
 
843
843
  .jewelry-leave-checkout__btn--leave {
844
- background: #3b1f4e;
845
- border: 1px solid #3b1f4e;
844
+ background: var(--color-brand-accent, #13264d);
845
+ border: 1px solid var(--color-brand-accent, #13264d);
846
846
  color: #ffffff;
847
847
  }
848
848
 
849
849
  .jewelry-leave-checkout__btn--leave:hover {
850
- background: #55306c;
851
- border-color: #55306c;
850
+ background: var(--color-brand-accent-hover, #1c3a70);
851
+ border-color: var(--color-brand-accent-hover, #1c3a70);
852
852
  }
853
853
 
854
854
  .jewelry-leave-checkout__btn:focus-visible {
855
- outline: 2px solid #3b1f4e;
855
+ outline: 2px solid var(--color-brand-accent, #13264d);
856
856
  outline-offset: 2px;
857
857
  }
858
858
 
@@ -894,7 +894,7 @@
894
894
  width: 3px;
895
895
  height: 0.95em;
896
896
  border-radius: 1px;
897
- background: #3b1f4e;
897
+ background: var(--color-brand-accent, #13264d);
898
898
  }
899
899
 
900
900
  .jewelry-checkout-bag__count {
@@ -915,29 +915,42 @@
915
915
 
916
916
  .jewelry-checkout-bag__item {
917
917
  display: grid;
918
- grid-template-columns: 5.5rem minmax(0, 1fr) auto;
918
+ grid-template-columns: 7.25rem minmax(0, 1fr) auto;
919
919
  gap: 1rem;
920
920
  align-items: center;
921
921
  padding: 0.85rem;
922
- border: 1px solid rgba(59, 31, 78, 0.12);
923
- border-radius: 0.85rem;
922
+ border: 1px solid rgba(var(--color-brand-accent-rgb, 19, 38, 77), 0.12);
923
+ border-radius: 1.15rem;
924
924
  background: #ffffff;
925
925
  }
926
926
 
927
927
  .jewelry-checkout-bag__thumb {
928
928
  position: relative;
929
- width: 5.5rem;
930
- height: 5.5rem;
929
+ width: 7.25rem;
930
+ height: 7.25rem;
931
931
  overflow: hidden;
932
- border-radius: 0.7rem;
933
- background: #faf6f1;
934
- border: 1px solid rgba(59, 31, 78, 0.1);
932
+ border-radius: 1rem;
933
+ background: linear-gradient(
934
+ 180deg,
935
+ var(--color-surface-muted, #faf7f5) 0%,
936
+ var(--color-brand-accent-muted, #ebf3f6) 100%
937
+ );
938
+ box-shadow: 0 16px 32px -22px rgba(var(--color-brand-accent-rgb, 19, 38, 77), 0.45);
939
+ }
940
+
941
+ .jewelry-checkout-bag__img {
942
+ object-fit: contain;
943
+ padding: 0.45rem;
935
944
  }
936
945
 
937
946
  .jewelry-checkout-bag__thumb-empty {
938
947
  width: 100%;
939
948
  height: 100%;
940
- background: linear-gradient(135deg, #faf6f1 0%, #f4eff8 100%);
949
+ background: linear-gradient(
950
+ 180deg,
951
+ var(--color-surface-muted, #faf7f5) 0%,
952
+ var(--color-brand-accent-muted, #ebf3f6) 100%
953
+ );
941
954
  }
942
955
 
943
956
  .jewelry-checkout-bag__meta {
@@ -956,6 +969,37 @@
956
969
  color: #1a1224;
957
970
  }
958
971
 
972
+ .jewelry-checkout-bag__variants {
973
+ display: flex;
974
+ flex-wrap: wrap;
975
+ gap: 0.4rem;
976
+ margin: 0.15rem 0 0;
977
+ padding: 0;
978
+ list-style: none;
979
+ }
980
+
981
+ .jewelry-checkout-bag__variant-chip {
982
+ display: inline-flex;
983
+ align-items: center;
984
+ gap: 0.35rem;
985
+ min-height: 1.7rem;
986
+ padding: 0.15rem 0.65rem;
987
+ border: 1px solid rgba(var(--color-brand-accent-rgb, 19, 38, 77), 0.16);
988
+ border-radius: 999px;
989
+ background: var(--color-brand-accent-muted, #ebf3f6);
990
+ font-size: 0.75rem;
991
+ font-weight: 600;
992
+ color: var(--color-text-heading, #0a0a0a);
993
+ }
994
+
995
+ .jewelry-checkout-bag__variant-label {
996
+ font-size: 0.625rem;
997
+ font-weight: 700;
998
+ letter-spacing: 0.08em;
999
+ text-transform: uppercase;
1000
+ color: var(--color-brand-accent, #13264d);
1001
+ }
1002
+
959
1003
  .jewelry-checkout-bag__variant {
960
1004
  margin: 0;
961
1005
  font-size: 0.6875rem;
@@ -975,18 +1019,18 @@
975
1019
  margin: 0;
976
1020
  font-size: 0.9375rem;
977
1021
  font-weight: 700;
978
- color: #3b1f4e;
1022
+ color: var(--color-brand-accent, #13264d);
979
1023
  white-space: nowrap;
980
1024
  }
981
1025
 
982
1026
  @media (max-width: 520px) {
983
1027
  .jewelry-checkout-bag__item {
984
- grid-template-columns: 4.25rem minmax(0, 1fr);
1028
+ grid-template-columns: 5.5rem minmax(0, 1fr);
985
1029
  }
986
1030
 
987
1031
  .jewelry-checkout-bag__thumb {
988
- width: 4.25rem;
989
- height: 4.25rem;
1032
+ width: 5.5rem;
1033
+ height: 5.5rem;
990
1034
  }
991
1035
 
992
1036
  .jewelry-checkout-bag__total {
@@ -994,3 +1038,60 @@
994
1038
  justify-self: start;
995
1039
  }
996
1040
  }
1041
+
1042
+ .jewelry-checkout-payment__btn--upi {
1043
+ background: var(--color-brand-accent, #13264d) !important;
1044
+ border: 1px solid var(--color-brand-accent, #13264d) !important;
1045
+ color: #ffffff !important;
1046
+ }
1047
+
1048
+ .jewelry-checkout-payment__btn--upi:hover:not(:disabled) {
1049
+ background: var(--color-brand-accent-hover, #1c3a70) !important;
1050
+ border-color: var(--color-brand-accent-hover, #1c3a70) !important;
1051
+ color: #ffffff !important;
1052
+ }
1053
+
1054
+ .jewelry-checkout-upi {
1055
+ display: flex;
1056
+ flex-direction: column;
1057
+ gap: 0.75rem;
1058
+ }
1059
+
1060
+ .jewelry-checkout-upi__qr {
1061
+ display: flex;
1062
+ flex-direction: column;
1063
+ align-items: center;
1064
+ gap: 0.5rem;
1065
+ padding: 1rem;
1066
+ border: 1px solid rgba(59, 31, 78, 0.16);
1067
+ background: #ffffff;
1068
+ }
1069
+
1070
+ .jewelry-checkout-upi__qr-img {
1071
+ height: auto;
1072
+ width: 100%;
1073
+ max-width: 280px;
1074
+ object-fit: contain;
1075
+ background: #ffffff;
1076
+ }
1077
+
1078
+ .jewelry-checkout-upi__qr-img--lg {
1079
+ width: 20rem;
1080
+ height: 20rem;
1081
+ max-width: 100%;
1082
+ padding: 0.5rem;
1083
+ }
1084
+
1085
+ .jewelry-checkout-upi__hint {
1086
+ margin: 0;
1087
+ text-align: center;
1088
+ font-size: 0.8125rem;
1089
+ color: #6b6573;
1090
+ }
1091
+
1092
+ .jewelry-checkout-upi__open-link {
1093
+ font-size: 0.8125rem;
1094
+ font-weight: 600;
1095
+ color: var(--color-brand-accent, #13264d);
1096
+ text-decoration: underline;
1097
+ }
@@ -11,12 +11,15 @@ import {
11
11
  openHostedCheckout,
12
12
  pickHostedPaymentProvider,
13
13
  pickManualPaymentProvider,
14
+ pickUpiPaymentProvider,
14
15
  placeCodOrder,
16
+ startUpiIntentCheckout,
15
17
  } from "./hosted-checkout"
16
18
  import { prepareCheckoutFromForm } from "./prepare-checkout"
17
19
  import { trackAddPaymentInfo } from "@pradip1995/segment-analytics/ecommerce-events"
18
20
  import { mapCartToEcommercePayload } from "@pradip1995/commerce-core/analytics/mappers"
19
21
  import { getPaymentGatewayKind } from "@pradip1995/commerce-core/util/payment-providers"
22
+ import { UpiIntentPaymentButton } from "./upi-intent-payment-button"
20
23
  import "./jewelry-checkout-form.css"
21
24
 
22
25
  type PaymentBlockProps = {
@@ -29,22 +32,32 @@ type PaymentBlockProps = {
29
32
  export default function PaymentBlock({ cart, paymentProviders, disabled, shopName }: PaymentBlockProps) {
30
33
  const [error, setError] = useState<string | null>(null)
31
34
  const [pending, startTransition] = useTransition()
35
+ const [upiSession, setUpiSession] = useState<HttpTypes.StorePaymentSession | null>(null)
36
+ const [upiCart, setUpiCart] = useState<HttpTypes.StoreCart | null>(null)
32
37
 
33
38
  const providerList = paymentProviders ?? cart.region?.payment_providers ?? []
34
39
  const onlineProviderId = pickHostedPaymentProvider(providerList)
40
+ const upiProviderId = pickUpiPaymentProvider(providerList)
35
41
  const codProviderId = pickManualPaymentProvider(providerList)
36
42
  const totalLabel = formatPrice(cart.total, cart.currency_code)
37
43
  const onlineKind = getPaymentGatewayKind(onlineProviderId ?? undefined)
38
44
  const onlineHint =
39
- onlineKind === "cashfree"
40
- ? `Your delivery address is saved when you pay. Online payments use Cashfree (${
41
- process.env.NEXT_PUBLIC_CASHFREE_ENVIRONMENT === "production" ? "live" : "test"
42
- } mode).`
43
- : onlineKind === "razorpay"
44
- ? "Your delivery address is saved when you pay. Online payments use Razorpay (test mode)."
45
+ upiProviderId || onlineKind === "razorpay"
46
+ ? "Your delivery address is saved when you pay. 1-click UPI opens your UPI app or shows a QR code."
47
+ : onlineKind === "cashfree"
48
+ ? `Your delivery address is saved when you pay. Online payments use Cashfree (${
49
+ process.env.NEXT_PUBLIC_CASHFREE_ENVIRONMENT === "production" ? "live" : "test"
50
+ } mode).`
45
51
  : "Your delivery address is saved when you pay."
46
52
 
47
- const runCheckout = (mode: "online" | "cod") => {
53
+ const onlineLabel =
54
+ onlineKind === "razorpay"
55
+ ? "Pay with Razorpay"
56
+ : onlineKind === "cashfree"
57
+ ? "Pay with Cashfree"
58
+ : "Pay online"
59
+
60
+ const runCheckout = (mode: "online" | "cod" | "upi") => {
48
61
  setError(null)
49
62
  startTransition(async () => {
50
63
  try {
@@ -56,6 +69,19 @@ export default function PaymentBlock({ cart, paymentProviders, disabled, shopNam
56
69
  return
57
70
  }
58
71
 
72
+ if (mode === "upi") {
73
+ if (!upiProviderId) {
74
+ throw new Error("1-click UPI is not configured for this region.")
75
+ }
76
+ const started = await startUpiIntentCheckout({
77
+ cart: freshCart,
78
+ providerId: upiProviderId,
79
+ })
80
+ setUpiCart(started.cart)
81
+ setUpiSession(started.session)
82
+ return
83
+ }
84
+
59
85
  if (!onlineProviderId) {
60
86
  throw new Error("Online payment is not configured.")
61
87
  }
@@ -68,16 +94,45 @@ export default function PaymentBlock({ cart, paymentProviders, disabled, shopNam
68
94
  })
69
95
  }
70
96
 
71
- const onlineLabel =
72
- onlineKind === "razorpay"
73
- ? "Pay with Razorpay"
74
- : onlineKind === "cashfree"
75
- ? "Pay with Cashfree"
76
- : "Pay online"
97
+ if (upiSession && upiCart) {
98
+ return (
99
+ <div className="checkout-payment jewelry-checkout-payment">
100
+ <UpiIntentPaymentButton
101
+ session={upiSession}
102
+ cart={upiCart}
103
+ autoStart
104
+ totalLabel={totalLabel}
105
+ />
106
+ <button
107
+ type="button"
108
+ className="checkout-page__btn checkout-page__btn--outline checkout-page__btn--block jewelry-checkout-payment__btn mt-3"
109
+ onClick={() => {
110
+ setUpiSession(null)
111
+ setUpiCart(null)
112
+ setError(null)
113
+ }}
114
+ >
115
+ Choose another payment method
116
+ </button>
117
+ {error && <p className="checkout-payment__error text-sm text-red-600 mt-2">{error}</p>}
118
+ </div>
119
+ )
120
+ }
77
121
 
78
122
  return (
79
123
  <div className="checkout-payment jewelry-checkout-payment">
80
124
  <div className="checkout-payment__actions">
125
+ {upiProviderId ? (
126
+ <button
127
+ type="button"
128
+ onClick={() => runCheckout("upi")}
129
+ disabled={disabled || pending}
130
+ className="checkout-page__btn checkout-page__btn--primary checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--upi"
131
+ >
132
+ {pending ? "Processing…" : `Pay with 1-click UPI · ${totalLabel}`}
133
+ </button>
134
+ ) : null}
135
+
81
136
  {codProviderId ? (
82
137
  <button
83
138
  type="button"
@@ -94,7 +149,11 @@ export default function PaymentBlock({ cart, paymentProviders, disabled, shopNam
94
149
  type="button"
95
150
  onClick={() => runCheckout("online")}
96
151
  disabled={disabled || pending}
97
- className="checkout-page__btn checkout-page__btn--primary checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--online"
152
+ className={
153
+ upiProviderId
154
+ ? "checkout-page__btn checkout-page__btn--outline checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--cod"
155
+ : "checkout-page__btn checkout-page__btn--primary checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--online"
156
+ }
98
157
  >
99
158
  {pending ? "Processing…" : onlineLabel}
100
159
  </button>
@@ -0,0 +1,212 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+ import { placeOrder } from "@pradip1995/commerce-core/client/actions/cart"
5
+ import { isNextRedirect } from "@pradip1995/segment-primitives/is-next-redirect"
6
+ import type { HttpTypes } from "@medusajs/types"
7
+ import { getUpiQrPaid } from "./upi-qr-status"
8
+ import {
9
+ getDesktopUpiQrSrc,
10
+ getRazorpayQrId,
11
+ getUpiDeepLink,
12
+ isMobileUpiClient,
13
+ upiQrImageUrl,
14
+ } from "./upi-intent"
15
+
16
+ type UpiIntentPaymentButtonProps = {
17
+ session: HttpTypes.StorePaymentSession
18
+ cart: HttpTypes.StoreCart
19
+ notReady?: boolean
20
+ autoStart?: boolean
21
+ totalLabel?: string
22
+ }
23
+
24
+ const POLL_ATTEMPTS = 15
25
+ const POLL_INTERVAL_MS = 2000
26
+
27
+ function sleep(ms: number): Promise<void> {
28
+ return new Promise((resolve) => setTimeout(resolve, ms))
29
+ }
30
+
31
+ export function UpiIntentPaymentButton({
32
+ session,
33
+ cart,
34
+ notReady = false,
35
+ autoStart = false,
36
+ totalLabel,
37
+ }: UpiIntentPaymentButtonProps) {
38
+ const [submitting, setSubmitting] = useState(false)
39
+ const [errorMessage, setErrorMessage] = useState<string | undefined>()
40
+ const [intentUrl, setIntentUrl] = useState<string | undefined>()
41
+ const [paymentStarted, setPaymentStarted] = useState(false)
42
+ const pollingRef = useRef(false)
43
+ const autoStartedRef = useRef(false)
44
+
45
+ const onPaymentCompleted = useCallback(async () => {
46
+ await placeOrder(cart.id).catch((err: unknown) => {
47
+ if (isNextRedirect(err)) {
48
+ throw err
49
+ }
50
+ setErrorMessage(
51
+ "Waiting for UPI confirmation. Tap “I’ve paid” after completing payment."
52
+ )
53
+ })
54
+ }, [cart.id])
55
+
56
+ const pollForOrder = useCallback(
57
+ async (showOverlay: boolean) => {
58
+ if (pollingRef.current) {
59
+ return
60
+ }
61
+ pollingRef.current = true
62
+ if (showOverlay) {
63
+ setSubmitting(true)
64
+ }
65
+ try {
66
+ const qrId = getRazorpayQrId(session.data)
67
+ for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
68
+ const paid = qrId ? await getUpiQrPaid(qrId) : false
69
+ if (!paid) {
70
+ await sleep(POLL_INTERVAL_MS)
71
+ continue
72
+ }
73
+ try {
74
+ await onPaymentCompleted()
75
+ } catch (err: unknown) {
76
+ if (isNextRedirect(err)) {
77
+ throw err
78
+ }
79
+ }
80
+ await sleep(POLL_INTERVAL_MS)
81
+ }
82
+ setErrorMessage(
83
+ "UPI payment not confirmed yet. Complete the payment in your UPI app, then tap “I’ve paid”."
84
+ )
85
+ } finally {
86
+ pollingRef.current = false
87
+ setSubmitting(false)
88
+ }
89
+ },
90
+ [onPaymentCompleted, session.data]
91
+ )
92
+
93
+ useEffect(() => {
94
+ const onVisible = () => {
95
+ if (document.visibilityState === "visible" && intentUrl) {
96
+ void pollForOrder(true)
97
+ }
98
+ }
99
+ document.addEventListener("visibilitychange", onVisible)
100
+ return () => document.removeEventListener("visibilitychange", onVisible)
101
+ }, [intentUrl, pollForOrder])
102
+
103
+ const handlePayment = useCallback(async () => {
104
+ setErrorMessage(undefined)
105
+ setSubmitting(true)
106
+
107
+ const url = getUpiDeepLink(session.data)
108
+ const qrSrc = getDesktopUpiQrSrc(session.data)
109
+ try {
110
+ if (!url && !qrSrc) {
111
+ throw new Error(
112
+ "UPI QR was not created. Try 1-click UPI again, or ask Razorpay to enable UPI QR Codes on this account."
113
+ )
114
+ }
115
+ setPaymentStarted(true)
116
+ if (url) {
117
+ setIntentUrl(url)
118
+ }
119
+ if (isMobileUpiClient()) {
120
+ if (url) {
121
+ window.location.href = url
122
+ }
123
+ setSubmitting(false)
124
+ return
125
+ }
126
+ setSubmitting(false)
127
+ void pollForOrder(false)
128
+ } catch (err: unknown) {
129
+ if (isNextRedirect(err)) {
130
+ throw err
131
+ }
132
+ const message =
133
+ err instanceof Error ? err.message : "Could not start UPI payment"
134
+ setErrorMessage(message)
135
+ setSubmitting(false)
136
+ }
137
+ }, [pollForOrder, session.data])
138
+
139
+ useEffect(() => {
140
+ if (!autoStart || notReady || autoStartedRef.current) {
141
+ return
142
+ }
143
+ autoStartedRef.current = true
144
+ void handlePayment()
145
+ }, [autoStart, handlePayment, notReady])
146
+
147
+ const resolvedIntentUrl =
148
+ (intentUrl && /^upi:/i.test(intentUrl) ? intentUrl : undefined) ||
149
+ getUpiDeepLink(session.data)
150
+ const qrSrc =
151
+ (resolvedIntentUrl ? upiQrImageUrl(resolvedIntentUrl) : undefined) ||
152
+ getDesktopUpiQrSrc(session.data)
153
+ const isGeneratedQr = Boolean(resolvedIntentUrl)
154
+ const isMobile = isMobileUpiClient()
155
+ const showQr = Boolean(qrSrc) && (!isMobile || !resolvedIntentUrl)
156
+
157
+ return (
158
+ <div className="jewelry-checkout-upi">
159
+ {showQr && qrSrc ? (
160
+ <div className="jewelry-checkout-upi__qr">
161
+ <img
162
+ alt="Scan to pay with UPI"
163
+ src={qrSrc}
164
+ width={isGeneratedQr ? 320 : undefined}
165
+ height={isGeneratedQr ? 320 : undefined}
166
+ className={
167
+ isGeneratedQr
168
+ ? "jewelry-checkout-upi__qr-img jewelry-checkout-upi__qr-img--lg"
169
+ : "jewelry-checkout-upi__qr-img"
170
+ }
171
+ />
172
+ <p className="jewelry-checkout-upi__hint">
173
+ Scan with GPay, PhonePe, Paytm, or any UPI app
174
+ </p>
175
+ {resolvedIntentUrl ? (
176
+ <a href={resolvedIntentUrl} className="jewelry-checkout-upi__open-link">
177
+ Open in UPI app
178
+ </a>
179
+ ) : null}
180
+ </div>
181
+ ) : null}
182
+
183
+ <button
184
+ type="button"
185
+ disabled={submitting || notReady}
186
+ onClick={async (e) => {
187
+ e.preventDefault()
188
+ if (paymentStarted) {
189
+ await pollForOrder(true)
190
+ return
191
+ }
192
+ await handlePayment()
193
+ }}
194
+ className="checkout-page__btn checkout-page__btn--primary checkout-page__btn--block jewelry-checkout-payment__btn jewelry-checkout-payment__btn--upi"
195
+ >
196
+ {submitting
197
+ ? "Confirming…"
198
+ : showQr || paymentStarted
199
+ ? "I’ve paid"
200
+ : totalLabel
201
+ ? `Pay with 1-click UPI · ${totalLabel}`
202
+ : "Pay with 1-click UPI"}
203
+ </button>
204
+
205
+ {errorMessage && !submitting ? (
206
+ <p className="checkout-payment__error text-sm text-red-600 mt-2">
207
+ {errorMessage}
208
+ </p>
209
+ ) : null}
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,99 @@
1
+ export const RAZORPAY_UPI_PROVIDER_ID = "pp_razorpay_upi_razorpay_upi"
2
+
3
+ export function asSessionData(data: unknown): Record<string, unknown> {
4
+ if (data && typeof data === "object" && !Array.isArray(data)) {
5
+ return data as Record<string, unknown>
6
+ }
7
+ return {}
8
+ }
9
+
10
+ function readString(
11
+ data: Record<string, unknown>,
12
+ key: string
13
+ ): string | undefined {
14
+ const value = data[key]
15
+ return typeof value === "string" && value.length > 0 ? value : undefined
16
+ }
17
+
18
+ function readInstrumentIntentUrl(
19
+ data: Record<string, unknown>
20
+ ): string | undefined {
21
+ const instrument = asSessionData(
22
+ data.instrumentResponse ?? data.instrument_response
23
+ )
24
+ const redirect = asSessionData(
25
+ instrument.redirectInfo ?? instrument.redirect_info
26
+ )
27
+ return (
28
+ readString(instrument, "intentUrl") ||
29
+ readString(instrument, "intent_url") ||
30
+ readString(redirect, "url")
31
+ )
32
+ }
33
+
34
+ function isRazorpayQrDownloadUrl(url: string): boolean {
35
+ return /(?:^https?:\/\/)?(?:www\.)?rzp\.io\//i.test(url)
36
+ }
37
+
38
+ export function getUpiIntentUrl(data: unknown): string | undefined {
39
+ const session = asSessionData(data)
40
+ const nested = asSessionData(session.data)
41
+ const candidates = [
42
+ readString(session, "upi_intent_url"),
43
+ readString(session, "image_content"),
44
+ readString(session, "payment_url"),
45
+ readInstrumentIntentUrl(session),
46
+ readString(nested, "upi_intent_url"),
47
+ readString(nested, "image_content"),
48
+ readString(nested, "payment_url"),
49
+ readInstrumentIntentUrl(nested),
50
+ ]
51
+ return (
52
+ candidates.find((url) => !!url && /^upi:/i.test(url)) ||
53
+ candidates.find((url) => !!url && !isRazorpayQrDownloadUrl(url))
54
+ )
55
+ }
56
+
57
+ export function getUpiDeepLink(data: unknown): string | undefined {
58
+ const url = getUpiIntentUrl(data)
59
+ return url && /^upi:/i.test(url) ? url : undefined
60
+ }
61
+
62
+ export function getRazorpayQrId(data: unknown): string | undefined {
63
+ const session = asSessionData(data)
64
+ const nested = asSessionData(session.data)
65
+ return readString(session, "qr_id") || readString(nested, "qr_id")
66
+ }
67
+
68
+ export function getRazorpayQrImageUrl(data: unknown): string | undefined {
69
+ const session = asSessionData(data)
70
+ const nested = asSessionData(session.data)
71
+ return readString(session, "image_url") || readString(nested, "image_url")
72
+ }
73
+
74
+ export function isUpiIntentFlow(data: unknown): boolean {
75
+ return readString(asSessionData(data), "flow") === "upi_intent"
76
+ }
77
+
78
+ export function isOneClickUpi(providerId?: string): boolean {
79
+ return !!providerId && providerId.includes("razorpay_upi")
80
+ }
81
+
82
+ export function isMobileUpiClient(): boolean {
83
+ if (typeof navigator === "undefined") {
84
+ return false
85
+ }
86
+ return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
87
+ }
88
+
89
+ export function upiQrImageUrl(intentUrl: string): string {
90
+ return `https://api.qrserver.com/v1/create-qr-code/?size=320x320&ecc=M&margin=8&data=${encodeURIComponent(intentUrl)}`
91
+ }
92
+
93
+ export function getDesktopUpiQrSrc(data: unknown): string | undefined {
94
+ const deepLink = getUpiDeepLink(data)
95
+ if (deepLink) {
96
+ return upiQrImageUrl(deepLink)
97
+ }
98
+ return getRazorpayQrImageUrl(data)
99
+ }
@@ -0,0 +1,39 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Poll Razorpay UPI QR payment status via Medusa store API.
5
+ * Uses publishable key only — same contract as medusa-payment-provider.
6
+ */
7
+ export async function getUpiQrPaid(qrId: string): Promise<boolean> {
8
+ if (!qrId.startsWith("qr_")) {
9
+ return false
10
+ }
11
+
12
+ const base =
13
+ process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ||
14
+ process.env.MEDUSA_BACKEND_URL ||
15
+ "http://localhost:9000"
16
+ const publishableKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY?.trim()
17
+
18
+ try {
19
+ const url = new URL("/store/upi-intent/status", base)
20
+ url.searchParams.set("qr_id", qrId)
21
+
22
+ const response = await fetch(url.toString(), {
23
+ method: "GET",
24
+ headers: publishableKey
25
+ ? { "x-publishable-api-key": publishableKey }
26
+ : undefined,
27
+ cache: "no-store",
28
+ })
29
+
30
+ if (!response.ok) {
31
+ return false
32
+ }
33
+
34
+ const result = (await response.json()) as { paid?: boolean }
35
+ return result.paid === true
36
+ } catch {
37
+ return false
38
+ }
39
+ }