@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 +1 -1
- package/src/address-fields.tsx +63 -24
- package/src/hosted-checkout.ts +54 -3
- package/src/jewelry-checkout-form.css +57 -0
- package/src/payment-block.tsx +73 -14
- package/src/upi-intent-payment-button.tsx +212 -0
- package/src/upi-intent.ts +99 -0
- package/src/upi-qr-status.ts +39 -0
package/package.json
CHANGED
package/src/address-fields.tsx
CHANGED
|
@@ -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 (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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:
|
|
178
|
-
lastName:
|
|
210
|
+
fullName: "",
|
|
211
|
+
firstName: "",
|
|
212
|
+
lastName: "",
|
|
179
213
|
email,
|
|
180
|
-
phone: toFormPhoneValue(
|
|
181
|
-
address1:
|
|
182
|
-
address2:
|
|
183
|
-
postalCode:
|
|
184
|
-
city:
|
|
185
|
-
province:
|
|
186
|
-
countryCode:
|
|
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
|
-
|
|
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: {
|
package/src/hosted-checkout.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
}
|
package/src/payment-block.tsx
CHANGED
|
@@ -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 === "
|
|
40
|
-
?
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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=
|
|
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
|
+
}
|