@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 +1 -1
- package/src/checkout-bag-items.tsx +17 -3
- package/src/checkout-line.test.ts +55 -0
- package/src/checkout-line.ts +49 -1
- package/src/hosted-checkout.ts +54 -3
- package/src/jewelry-checkout-form.css +157 -56
- 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
|
@@ -40,8 +40,8 @@ export default function CheckoutBagItems({
|
|
|
40
40
|
src={thumbnail}
|
|
41
41
|
alt={line.title}
|
|
42
42
|
fill
|
|
43
|
-
className="
|
|
44
|
-
sizes="
|
|
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.
|
|
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, () => {
|
package/src/checkout-line.ts
CHANGED
|
@@ -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?: {
|
|
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,
|
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
|
|
@@ -18,20 +18,20 @@
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
.jewelry-checkout-payment__btn--cod:hover:not(:disabled) {
|
|
21
|
-
border-color: #
|
|
22
|
-
color: #
|
|
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: #
|
|
28
|
-
border: 1px solid #
|
|
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: #
|
|
34
|
-
border-color: #
|
|
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, #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
160
|
-
border: 1px solid #
|
|
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: #
|
|
166
|
-
border-color: #
|
|
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: #
|
|
178
|
-
color: #
|
|
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 #
|
|
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: #
|
|
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 #
|
|
243
|
+
border: 1.5px solid var(--color-brand-accent, #13264d);
|
|
244
244
|
border-radius: 999px;
|
|
245
|
-
background: #
|
|
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: #
|
|
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: #
|
|
304
|
-
border-color: #
|
|
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: #
|
|
369
|
-
color: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
649
|
-
border-color: #
|
|
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: #
|
|
688
|
-
border: 1px solid #
|
|
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: #
|
|
710
|
-
color: #
|
|
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: #
|
|
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: #
|
|
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 #
|
|
836
|
-
color: #
|
|
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: #
|
|
845
|
-
border: 1px solid #
|
|
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: #
|
|
851
|
-
border-color: #
|
|
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 #
|
|
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: #
|
|
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:
|
|
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(
|
|
923
|
-
border-radius:
|
|
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:
|
|
930
|
-
height:
|
|
929
|
+
width: 7.25rem;
|
|
930
|
+
height: 7.25rem;
|
|
931
931
|
overflow: hidden;
|
|
932
|
-
border-radius:
|
|
933
|
-
background:
|
|
934
|
-
|
|
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(
|
|
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: #
|
|
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:
|
|
1028
|
+
grid-template-columns: 5.5rem minmax(0, 1fr);
|
|
985
1029
|
}
|
|
986
1030
|
|
|
987
1031
|
.jewelry-checkout-bag__thumb {
|
|
988
|
-
width:
|
|
989
|
-
height:
|
|
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
|
+
}
|
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
|
+
}
|