@pradip1995/segment-jewelry-checkout-form 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -108,8 +108,7 @@ function pickPreferredFromList(
108
108
  addresses.find((a) => a.is_default_billing) ||
109
109
  addresses.find(
110
110
  (a) => a.metadata?.is_default === true || a.metadata?.is_default === "true"
111
- ) ||
112
- addresses[0]
111
+ )
113
112
  )
114
113
  }
115
114
 
@@ -156,34 +155,69 @@ function buildInitialValues(
156
155
  const matched = findMatchingSavedAddress(cart, addresses)
157
156
  const preferred = pickPreferredAddress(customer)
158
157
  const shipping = cart?.shipping_address
158
+ const fallbackCountry =
159
+ preferred?.country_code ||
160
+ matched?.country_code ||
161
+ shipping?.country_code ||
162
+ cart?.region?.countries?.[0]?.iso_2 ||
163
+ "in"
164
+
165
+ if (preferred) {
166
+ return addressToFormValues(preferred, {
167
+ email,
168
+ phone: customer?.phone,
169
+ countryCode: fallbackCountry,
170
+ })
171
+ }
159
172
 
160
- if (addresses.length) {
161
- const source = preferred || matched
162
- if (source) {
163
- return addressToFormValues(source, {
164
- email,
165
- phone: customer?.phone,
166
- countryCode: source.country_code || cart?.region?.countries?.[0]?.iso_2 || "in",
167
- })
173
+ if (matched) {
174
+ return addressToFormValues(matched, {
175
+ email,
176
+ phone: customer?.phone,
177
+ countryCode: fallbackCountry,
178
+ })
179
+ }
180
+
181
+ if (shipping?.address_1 || shipping?.postal_code) {
182
+ const fullName = shipping?.first_name
183
+ ? `${shipping.first_name} ${shipping.last_name === "." ? "" : shipping.last_name || ""}`.trim()
184
+ : ""
185
+
186
+ return {
187
+ fullName,
188
+ firstName: shipping?.first_name || "",
189
+ lastName: shipping?.last_name || "",
190
+ email,
191
+ phone: toFormPhoneValue(shipping?.phone || customer?.phone),
192
+ address1: shipping?.address_1 || "",
193
+ address2: shipping?.address_2 || "",
194
+ postalCode: shipping?.postal_code || "",
195
+ city: shipping?.city || "",
196
+ province: shipping?.province || "",
197
+ countryCode: fallbackCountry,
168
198
  }
169
199
  }
170
200
 
171
- const fullName = shipping?.first_name
172
- ? `${shipping.first_name} ${shipping.last_name === "." ? "" : shipping.last_name || ""}`.trim()
173
- : ""
201
+ if (addresses[0]) {
202
+ return addressToFormValues(addresses[0], {
203
+ email,
204
+ phone: customer?.phone,
205
+ countryCode: fallbackCountry,
206
+ })
207
+ }
174
208
 
175
209
  return {
176
- fullName,
177
- firstName: shipping?.first_name || "",
178
- lastName: shipping?.last_name || "",
210
+ fullName: "",
211
+ firstName: "",
212
+ lastName: "",
179
213
  email,
180
- phone: toFormPhoneValue(shipping?.phone || customer?.phone),
181
- address1: shipping?.address_1 || "",
182
- address2: shipping?.address_2 || "",
183
- postalCode: shipping?.postal_code || "",
184
- city: shipping?.city || "",
185
- province: shipping?.province || "",
186
- countryCode: shipping?.country_code || cart?.region?.countries?.[0]?.iso_2 || "in",
214
+ phone: toFormPhoneValue(customer?.phone),
215
+ address1: "",
216
+ address2: "",
217
+ postalCode: "",
218
+ city: "",
219
+ province: "",
220
+ countryCode: fallbackCountry,
187
221
  }
188
222
  }
189
223
 
@@ -200,7 +234,12 @@ function resolveInitialSelectedId(
200
234
  const matched = findMatchingSavedAddress(cart, addresses)
201
235
  if (matched?.id) return matched.id
202
236
 
203
- return NEW_ADDRESS_ID
237
+ // Cart already has an address (e.g. reorder from a past order) — don't force another saved one.
238
+ if (cart?.shipping_address?.address_1 || cart?.shipping_address?.postal_code) {
239
+ return NEW_ADDRESS_ID
240
+ }
241
+
242
+ return addresses[0]?.id || NEW_ADDRESS_ID
204
243
  }
205
244
 
206
245
  function formatAddressParts(address: {
@@ -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
@@ -994,3 +994,60 @@
994
994
  justify-self: start;
995
995
  }
996
996
  }
997
+
998
+ .jewelry-checkout-payment__btn--upi {
999
+ background: #3b1f4e !important;
1000
+ border: 1px solid #3b1f4e !important;
1001
+ color: #ffffff !important;
1002
+ }
1003
+
1004
+ .jewelry-checkout-payment__btn--upi:hover:not(:disabled) {
1005
+ background: #55306c !important;
1006
+ border-color: #55306c !important;
1007
+ color: #ffffff !important;
1008
+ }
1009
+
1010
+ .jewelry-checkout-upi {
1011
+ display: flex;
1012
+ flex-direction: column;
1013
+ gap: 0.75rem;
1014
+ }
1015
+
1016
+ .jewelry-checkout-upi__qr {
1017
+ display: flex;
1018
+ flex-direction: column;
1019
+ align-items: center;
1020
+ gap: 0.5rem;
1021
+ padding: 1rem;
1022
+ border: 1px solid rgba(59, 31, 78, 0.16);
1023
+ background: #ffffff;
1024
+ }
1025
+
1026
+ .jewelry-checkout-upi__qr-img {
1027
+ height: auto;
1028
+ width: 100%;
1029
+ max-width: 280px;
1030
+ object-fit: contain;
1031
+ background: #ffffff;
1032
+ }
1033
+
1034
+ .jewelry-checkout-upi__qr-img--lg {
1035
+ width: 20rem;
1036
+ height: 20rem;
1037
+ max-width: 100%;
1038
+ padding: 0.5rem;
1039
+ }
1040
+
1041
+ .jewelry-checkout-upi__hint {
1042
+ margin: 0;
1043
+ text-align: center;
1044
+ font-size: 0.8125rem;
1045
+ color: #6b6573;
1046
+ }
1047
+
1048
+ .jewelry-checkout-upi__open-link {
1049
+ font-size: 0.8125rem;
1050
+ font-weight: 600;
1051
+ color: #3b1f4e;
1052
+ text-decoration: underline;
1053
+ }
@@ -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
+ }