@12-apps/payments-frontend 1.7.1 → 1.8.1
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 +17 -6
- package/src/components/checkout/buyer-fields.ts +117 -0
- package/src/components/checkout/buyer-info-form.tsx +154 -85
- package/src/components/checkout/card-instruments.ts +65 -21
- package/src/components/checkout/checkout-flow.tsx +9 -2
- package/src/components/checkout/checkout-steps.tsx +58 -26
- package/src/components/checkout/client-context.tsx +57 -0
- package/src/components/checkout/client.ts +18 -110
- package/src/components/checkout/method-capability.ts +60 -10
- package/src/components/checkout/navigate-context.tsx +40 -0
- package/src/components/checkout/transport.ts +191 -0
- package/src/components/checkout/types.ts +27 -0
- package/src/components/checkout/ui.tsx +15 -3
- package/src/components/checkout/use-card-checkout.ts +30 -13
- package/src/components/checkout/use-checkout-controller.ts +64 -20
- package/src/components/checkout/use-payment-polling.ts +7 -3
- package/src/flows/copy.ts +57 -0
- package/src/flows/create-payment-flows.tsx +196 -0
- package/src/flows/runtime.tsx +149 -0
- package/src/flows/screens-buyer.tsx +170 -0
- package/src/flows/screens-hosted.tsx +149 -0
- package/src/flows/screens-pay.tsx +178 -0
- package/src/flows/types.ts +179 -0
- package/src/index.ts +35 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
cardPathAvailable,
|
|
10
10
|
cardTokenization,
|
|
11
11
|
offeredMethods,
|
|
12
|
+
selectableMethods,
|
|
12
13
|
usePreselectSoleMethod,
|
|
13
14
|
} from "./method-capability";
|
|
14
15
|
import { MethodPicker } from "./method-picker";
|
|
@@ -18,6 +19,7 @@ import { PixView } from "./pix-view";
|
|
|
18
19
|
import type {
|
|
19
20
|
BuyerField,
|
|
20
21
|
BuyerInfo,
|
|
22
|
+
CheckoutCustomerField,
|
|
21
23
|
CheckoutOrder,
|
|
22
24
|
CheckoutProviderConfig,
|
|
23
25
|
OrderStatus,
|
|
@@ -155,6 +157,7 @@ export function DadosStep({
|
|
|
155
157
|
errorField,
|
|
156
158
|
onContinue,
|
|
157
159
|
cartTotals,
|
|
160
|
+
buyerFields,
|
|
158
161
|
discountLines,
|
|
159
162
|
totalOverride,
|
|
160
163
|
}: {
|
|
@@ -167,6 +170,8 @@ export function DadosStep({
|
|
|
167
170
|
onContinue: () => void;
|
|
168
171
|
/** The host cart's own totals — what the pay bar shows in cart mode. */
|
|
169
172
|
cartTotals: { totalLabel: string; totalItems: number };
|
|
173
|
+
/** What the store's chain declares it needs (FUT-595); absent ⇒ CPF-required. */
|
|
174
|
+
buyerFields?: readonly CheckoutCustomerField[];
|
|
170
175
|
/**
|
|
171
176
|
* The saving, itemized under the total the buyer is about to authorize
|
|
172
177
|
* (FUT-246) — RENDERED BY THE HOST from its cart (the storefront passes its
|
|
@@ -177,7 +182,7 @@ export function DadosStep({
|
|
|
177
182
|
/** Comanda settlement (FUT-comandas): totals come from the comanda, not the cart. */
|
|
178
183
|
totalOverride?: { label: string; items: number };
|
|
179
184
|
}): JSX.Element {
|
|
180
|
-
const {
|
|
185
|
+
const { Checkbox } = useCheckoutComponents();
|
|
181
186
|
const { label: totalLabel, items: totalItems } = displayTotals(totalOverride, cartTotals);
|
|
182
187
|
|
|
183
188
|
return (
|
|
@@ -186,6 +191,7 @@ export function DadosStep({
|
|
|
186
191
|
<BuyerInfoForm
|
|
187
192
|
value={buyer}
|
|
188
193
|
onChange={onBuyerChange}
|
|
194
|
+
fields={buyerFields}
|
|
189
195
|
fieldError={errorField && createError ? { field: errorField, message: createError } : null}
|
|
190
196
|
/>
|
|
191
197
|
<Checkbox
|
|
@@ -196,32 +202,57 @@ export function DadosStep({
|
|
|
196
202
|
/>
|
|
197
203
|
</Box>
|
|
198
204
|
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
205
|
+
<DadosPayBar
|
|
206
|
+
totalLabel={totalLabel}
|
|
207
|
+
totalItems={totalItems}
|
|
208
|
+
createError={createError}
|
|
209
|
+
onContinue={onContinue}
|
|
210
|
+
>
|
|
211
|
+
{/* Suppressed while settling a comanda: those totals come from the
|
|
212
|
+
frozen ticket, not the cart. */}
|
|
213
|
+
{totalOverride ? null : discountLines}
|
|
214
|
+
</DadosPayBar>
|
|
215
|
+
</>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The sticky "Continuar" bar: the refusal, the money, and the one action. */
|
|
220
|
+
function DadosPayBar({
|
|
221
|
+
totalLabel,
|
|
222
|
+
totalItems,
|
|
223
|
+
createError,
|
|
224
|
+
onContinue,
|
|
225
|
+
children,
|
|
226
|
+
}: {
|
|
227
|
+
totalLabel: string;
|
|
228
|
+
totalItems: number;
|
|
229
|
+
createError: string | null;
|
|
230
|
+
onContinue: () => void;
|
|
231
|
+
children?: ReactNode;
|
|
232
|
+
}): JSX.Element {
|
|
233
|
+
const { ActionBar, Alert, Button, Text } = useCheckoutComponents();
|
|
234
|
+
return (
|
|
235
|
+
<ActionBar dataTestId="checkout-pay-bar">
|
|
236
|
+
<Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
237
|
+
{createError ? (
|
|
238
|
+
<Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
|
|
239
|
+
) : null}
|
|
240
|
+
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
|
241
|
+
<PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>{children}</PayBarTotal>
|
|
242
|
+
<Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
|
|
243
|
+
<Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
|
|
244
|
+
Continuar
|
|
245
|
+
</Button>
|
|
246
|
+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
|
|
247
|
+
<LockOutlinedIcon sx={{ fontSize: 13 }} />
|
|
248
|
+
<Text variant="caption" size="xs" color="secondary" as="span">
|
|
249
|
+
Pagamento seguro
|
|
250
|
+
</Text>
|
|
220
251
|
</Box>
|
|
221
252
|
</Box>
|
|
222
253
|
</Box>
|
|
223
|
-
</
|
|
224
|
-
|
|
254
|
+
</Box>
|
|
255
|
+
</ActionBar>
|
|
225
256
|
);
|
|
226
257
|
}
|
|
227
258
|
|
|
@@ -283,8 +314,9 @@ export function PaymentStep({
|
|
|
283
314
|
}: PaymentStepProps): JSX.Element {
|
|
284
315
|
const { LoadingState } = useCheckoutComponents();
|
|
285
316
|
const cardUnavailable = !cardPathAvailable(providerConfig ?? null);
|
|
317
|
+
const offered = offeredMethods(providerConfig ?? null);
|
|
286
318
|
useAutoRaiseOrder(order, method, creating, createError, onGenerate);
|
|
287
|
-
usePreselectSoleMethod(cardUnavailable, method, onMethodChange);
|
|
319
|
+
usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), method, onMethodChange);
|
|
288
320
|
|
|
289
321
|
return (
|
|
290
322
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
@@ -295,7 +327,7 @@ export function PaymentStep({
|
|
|
295
327
|
value={method}
|
|
296
328
|
onChange={onMethodChange}
|
|
297
329
|
cardUnavailable={cardUnavailable}
|
|
298
|
-
offered={
|
|
330
|
+
offered={offered}
|
|
299
331
|
/>
|
|
300
332
|
|
|
301
333
|
<PaymentBody
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH client the checkout screens talk through (FUT-741).
|
|
3
|
+
*
|
|
4
|
+
* Before this, every screen imported the free functions in `client.ts`
|
|
5
|
+
* directly, so the mount they reach was a module-level constant: one prefix,
|
|
6
|
+
* the ambient `fetch`, no headers. That is right for the shipped storefront
|
|
7
|
+
* and impossible for anything else — a story, a harness page or a second host
|
|
8
|
+
* on a different mount could only get at the wire by replacing `globalThis.
|
|
9
|
+
* fetch`, which is a mock of OUR OWN client and the exact blind spot the
|
|
10
|
+
* FUT-740 review's three criticals lived in.
|
|
11
|
+
*
|
|
12
|
+
* So the client is context now, with the module-level one as the default. A
|
|
13
|
+
* tree with no provider behaves exactly as it did; a tree under
|
|
14
|
+
* `createPaymentFlows`'s provider talks through the bound transport.
|
|
15
|
+
*
|
|
16
|
+
* The default is built from the `client.ts` bindings LAZILY (inside each
|
|
17
|
+
* arrow), so a suite that `vi.mock`s that module still intercepts the call.
|
|
18
|
+
*/
|
|
19
|
+
import { createContext, useContext, type JSX, type ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
chargeCard,
|
|
23
|
+
fetchCheckoutConfig,
|
|
24
|
+
listSavedCards,
|
|
25
|
+
pollOrderStatus,
|
|
26
|
+
refreshCardPublicKey,
|
|
27
|
+
} from "./client";
|
|
28
|
+
import type { CheckoutClient } from "./transport";
|
|
29
|
+
|
|
30
|
+
/** The unbound client: `/api/checkout` on the ambient `fetch`. */
|
|
31
|
+
const DEFAULT_CLIENT: CheckoutClient = {
|
|
32
|
+
getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug),
|
|
33
|
+
getStatus: (ref) => pollOrderStatus(ref),
|
|
34
|
+
charge: (input) => chargeCard(input),
|
|
35
|
+
listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
|
|
36
|
+
refreshBrowserKey: (input) => refreshCardPublicKey(input),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const CheckoutClientContext = createContext<CheckoutClient | null>(null);
|
|
40
|
+
|
|
41
|
+
/** Point everything below at one bound {@link CheckoutClient}. */
|
|
42
|
+
export function CheckoutClientProvider({
|
|
43
|
+
client,
|
|
44
|
+
children,
|
|
45
|
+
}: {
|
|
46
|
+
client: CheckoutClient;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}): JSX.Element {
|
|
49
|
+
return (
|
|
50
|
+
<CheckoutClientContext.Provider value={client}>{children}</CheckoutClientContext.Provider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The bound client, or the module default when no provider sits above. */
|
|
55
|
+
export function useCheckoutClientApi(): CheckoutClient {
|
|
56
|
+
return useContext(CheckoutClientContext) ?? DEFAULT_CLIENT;
|
|
57
|
+
}
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* provider-protocol read, against the host-mounted `/api/checkout*` surface
|
|
5
5
|
* (documented in `packages/payments/ADOPTING.md` §3).
|
|
6
6
|
*
|
|
7
|
+
* Since FUT-741 these are the UNBOUND door onto `createCheckoutClient` — the
|
|
8
|
+
* same five calls, on the default `/api/checkout` prefix and the ambient
|
|
9
|
+
* `fetch`. They stay exported, and stay byte-identical on the wire, because
|
|
10
|
+
* they are what is already running in buyers' browsers and what both pinned
|
|
11
|
+
* wire suites drive. A host that needs a different mount, its own headers or an
|
|
12
|
+
* injected `fetch` reaches for `createPaymentFlows({ transport })` instead;
|
|
13
|
+
* nothing about these functions changes when it does.
|
|
14
|
+
*
|
|
7
15
|
* Order CREATION and the buyer-profile save are deliberately NOT here: both
|
|
8
16
|
* are host domain (the cart, the account) and reach the flow as ports
|
|
9
17
|
* (`createOrder` / `saveBuyerContact` on `CheckoutFlowProps`).
|
|
@@ -14,8 +22,9 @@
|
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
24
|
import type { SavedCard } from "../../card";
|
|
17
|
-
import {
|
|
25
|
+
import type { Result } from "../../result";
|
|
18
26
|
|
|
27
|
+
import { createCheckoutClient } from "./transport";
|
|
19
28
|
import type {
|
|
20
29
|
ChargeCardInput,
|
|
21
30
|
ChargeOutcome,
|
|
@@ -23,91 +32,15 @@ import type {
|
|
|
23
32
|
OrderStatus,
|
|
24
33
|
} from "./types";
|
|
25
34
|
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Transport
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
/** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
|
|
31
|
-
interface ApiEnvelope<T> {
|
|
32
|
-
data?: T;
|
|
33
|
-
error?: string;
|
|
34
|
-
/**
|
|
35
|
-
* Stable machine code for the failure (`checkoutErrorResponse` always sends
|
|
36
|
-
* one). Carried through so a surface can PRESENT a refusal for what it is —
|
|
37
|
-
* an unresolved charge is not a decline, and rendering it under "não foi
|
|
38
|
-
* possível pagar" with a live pay button invites the second payment its own
|
|
39
|
-
* text forbids.
|
|
40
|
-
*/
|
|
41
|
-
code?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
|
|
46
|
-
* The message is what the buyer reads; the code is what a surface uses to
|
|
47
|
-
* decide how to PRESENT it, which a message cannot be parsed for.
|
|
48
|
-
*/
|
|
49
|
-
function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
|
|
50
|
-
return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Call an API route and normalize the response into a {@link Result}. */
|
|
54
|
-
async function requestResult<T>(input: string, init?: RequestInit): Promise<Result<T>> {
|
|
55
|
-
try {
|
|
56
|
-
const res = await fetch(input, {
|
|
57
|
-
...init,
|
|
58
|
-
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
59
|
-
});
|
|
60
|
-
const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
|
|
61
|
-
if (!res.ok) return refused(json);
|
|
62
|
-
if (!json || json.data === undefined) {
|
|
63
|
-
return err(json?.error ?? "Resposta inválida do servidor.");
|
|
64
|
-
}
|
|
65
|
-
return ok(json.data);
|
|
66
|
-
} catch {
|
|
67
|
-
return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Public API
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
35
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* InfinitePay's `payment_check` refuses to confirm without BOTH the
|
|
80
|
-
* `transaction_nsu` and the invoice `slug`, and neither exists until somebody
|
|
81
|
-
* has actually paid — they arrive here, on the redirect. Left in the URL: the
|
|
82
|
-
* server treats them as hints, so re-sending them on later polls is harmless.
|
|
83
|
-
*
|
|
84
|
-
* Note the buyer has to press "Continuar" on the provider's receipt for this
|
|
85
|
-
* redirect to happen at all, which is exactly why it is a hint and never the
|
|
86
|
-
* mechanism: the webhook, and the server-side reconciliation behind it, are
|
|
87
|
-
* what must work when they simply close the tab.
|
|
36
|
+
* The default binding: `/api/checkout` on the ambient `fetch`. Built once, but
|
|
37
|
+
* it resolves `fetch` per call, so a suite that stubs the global still wins.
|
|
88
38
|
*/
|
|
89
|
-
|
|
90
|
-
if (typeof window === "undefined") return {};
|
|
91
|
-
const params = new URLSearchParams(window.location.search);
|
|
92
|
-
// BOTH. Measured against the live API: handle + order_nsu +
|
|
93
|
-
// transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
|
|
94
|
-
// same call missing EITHER answers {"success":false}. Neither exists before
|
|
95
|
-
// the payment, and the slug is not in the link-creation response — the
|
|
96
|
-
// redirect and the webhook are the only places both appear together.
|
|
97
|
-
const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
|
|
98
|
-
const slug = params.get("slug") ?? "";
|
|
99
|
-
return {
|
|
100
|
-
...(transactionNsu ? { transactionNsu } : {}),
|
|
101
|
-
...(slug ? { slug } : {}),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
39
|
+
const defaultClient = createCheckoutClient();
|
|
104
40
|
|
|
105
41
|
/** Poll an order's reconciled status (async provider webhook confirmation). */
|
|
106
42
|
export async function pollOrderStatus(orderId: string): Promise<Result<OrderStatus>> {
|
|
107
|
-
|
|
108
|
-
return requestResult<OrderStatus>(`/api/checkout/status?${params.toString()}`, {
|
|
109
|
-
method: "GET",
|
|
110
|
-
});
|
|
43
|
+
return defaultClient.getStatus(orderId);
|
|
111
44
|
}
|
|
112
45
|
|
|
113
46
|
/**
|
|
@@ -119,10 +52,7 @@ export async function pollOrderStatus(orderId: string): Promise<Result<OrderStat
|
|
|
119
52
|
export async function fetchCheckoutConfig(
|
|
120
53
|
tenantSlug: string,
|
|
121
54
|
): Promise<Result<CheckoutProviderConfig>> {
|
|
122
|
-
return
|
|
123
|
-
`/api/checkout/config?tenantSlug=${encodeURIComponent(tenantSlug)}`,
|
|
124
|
-
{ method: "GET" },
|
|
125
|
-
);
|
|
55
|
+
return defaultClient.getConfig(tenantSlug);
|
|
126
56
|
}
|
|
127
57
|
|
|
128
58
|
/**
|
|
@@ -135,10 +65,7 @@ export async function fetchCheckoutConfig(
|
|
|
135
65
|
export async function refreshCardPublicKey(input: {
|
|
136
66
|
orderId: string;
|
|
137
67
|
}): Promise<Result<{ publicKey: string | null }>> {
|
|
138
|
-
return
|
|
139
|
-
method: "POST",
|
|
140
|
-
body: JSON.stringify({ orderId: input.orderId }),
|
|
141
|
-
});
|
|
68
|
+
return defaultClient.refreshBrowserKey(input);
|
|
142
69
|
}
|
|
143
70
|
|
|
144
71
|
/**
|
|
@@ -147,29 +74,10 @@ export async function refreshCardPublicKey(input: {
|
|
|
147
74
|
* page (3-D Secure, FUT-698) — the caller then hands the buyer over.
|
|
148
75
|
*/
|
|
149
76
|
export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeOutcome>> {
|
|
150
|
-
return
|
|
151
|
-
method: "POST",
|
|
152
|
-
body: JSON.stringify({
|
|
153
|
-
orderId: input.orderId,
|
|
154
|
-
token: input.token,
|
|
155
|
-
// One instrument per provider (FUT-563) — the server hands each provider
|
|
156
|
-
// in the chain its own, which is what lets a card charge fail over.
|
|
157
|
-
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
158
|
-
saveCard: input.saveCard,
|
|
159
|
-
cardMeta: input.cardMeta,
|
|
160
|
-
taxId: input.taxId,
|
|
161
|
-
}),
|
|
162
|
-
});
|
|
77
|
+
return defaultClient.charge(input);
|
|
163
78
|
}
|
|
164
79
|
|
|
165
80
|
/** List saved cards available for reuse (empty on any error — non-blocking). */
|
|
166
81
|
export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
|
|
167
|
-
|
|
168
|
-
// provider can actually charge come back — a PagBank-vaulted card is not
|
|
169
|
-
// offered for a Stone charge it would fail (or misroute).
|
|
170
|
-
const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
|
171
|
-
const result = await requestResult<SavedCard[]>(`/api/checkout/cards${query}`, {
|
|
172
|
-
method: "GET",
|
|
173
|
-
});
|
|
174
|
-
return result.ok ? result.data : [];
|
|
82
|
+
return defaultClient.listInstruments(tenantSlug);
|
|
175
83
|
}
|
|
@@ -9,7 +9,7 @@ import { useEffect } from "react";
|
|
|
9
9
|
|
|
10
10
|
import { tokenizerFor, type CardTokenizationConfig } from "../../card";
|
|
11
11
|
|
|
12
|
-
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
12
|
+
import type { CheckoutChainLink, CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Whether the store's CHAIN gives this browser a card path (FUT-697/563).
|
|
@@ -26,16 +26,39 @@ import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
|
26
26
|
* Answering "yes, it is REDIRECT" off the head alone is what let the picker
|
|
27
27
|
* offer a card the submit could never tokenize.
|
|
28
28
|
*
|
|
29
|
+
* Asked only of the entries that DECLARE `CARD` (FUT-747), for the same reason
|
|
30
|
+
* the server's predicate is: tokenization is a card fact, so a PIX-only entry
|
|
31
|
+
* has nothing to say about the card path and must not be read as "this store
|
|
32
|
+
* hands the buyer over". A store with no card-capable entry at all has no card
|
|
33
|
+
* path — `offeredMethods` is already not offering one, and saying so here is
|
|
34
|
+
* what lets {@link usePreselectSoleMethod} pick the store's only method.
|
|
35
|
+
*
|
|
29
36
|
* `null` config (still loading / fetch blip) fails OPEN for the UI — the
|
|
30
37
|
* tokenizer itself still fails CLOSED.
|
|
31
38
|
*/
|
|
32
39
|
export function cardPathAvailable(config: CheckoutProviderConfig | null): boolean {
|
|
33
40
|
if (!config) return true;
|
|
34
|
-
const chain =
|
|
41
|
+
const chain = cardCapableChain(config);
|
|
42
|
+
if (chain.length === 0) return false;
|
|
35
43
|
if (!chain.some((link) => link.mintable)) return true;
|
|
36
44
|
return chain.some(canMintFor);
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The published chain narrowed to the entries that declare `CARD` — the same
|
|
49
|
+
* subset the server's walk will attempt, since it skips a provider whose
|
|
50
|
+
* capabilities exclude the method.
|
|
51
|
+
*
|
|
52
|
+
* A store that served NO chain (an older host, or a mocked config) has no
|
|
53
|
+
* per-entry `methods` to narrow on, so it degrades to {@link cardChain}'s head
|
|
54
|
+
* triple and behaves exactly as it did before.
|
|
55
|
+
*/
|
|
56
|
+
function cardCapableChain(config: CheckoutProviderConfig): CardChainLink[] {
|
|
57
|
+
const chain = config.chain;
|
|
58
|
+
if (!chain || chain.length === 0) return cardChain(config);
|
|
59
|
+
return chain.filter((link) => link.methods.includes("CARD")).map(toCardLink);
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
/** Whether this browser can really produce an instrument for ONE chain entry. */
|
|
40
63
|
function canMintFor(link: CardChainLink): boolean {
|
|
41
64
|
if (!link.mintable) return false;
|
|
@@ -104,12 +127,17 @@ export function cardChain(config: CheckoutProviderConfig | null): CardChainLink[
|
|
|
104
127
|
const mintable = config.tokenization === null || MINTABLE.has(config.tokenization);
|
|
105
128
|
return [{ ...cardTokenization(config), mintable }];
|
|
106
129
|
}
|
|
107
|
-
return chain.map(
|
|
130
|
+
return chain.map(toCardLink);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** ONE published chain entry, as the card path sees it. */
|
|
134
|
+
function toCardLink(link: CheckoutChainLink): CardChainLink {
|
|
135
|
+
return {
|
|
108
136
|
provider: link.provider,
|
|
109
137
|
publicKey: link.publicKey,
|
|
110
138
|
mockTokenization: link.mockTokenization,
|
|
111
139
|
mintable: MINTABLE.has(link.tokenization),
|
|
112
|
-
}
|
|
140
|
+
};
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
/**
|
|
@@ -128,16 +156,38 @@ export function offeredMethods(config: CheckoutProviderConfig | null): PaymentMe
|
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
159
|
+
* The methods a buyer can actually pick right now.
|
|
160
|
+
*
|
|
161
|
+
* `null` offered — still loading, or a fetch blip — fails OPEN and offers both;
|
|
162
|
+
* the server still refuses the charge closed. A CARD tile with no in-browser
|
|
163
|
+
* path is not selectable, so it does not count towards a "sole" method either.
|
|
134
164
|
*/
|
|
135
|
-
export function
|
|
165
|
+
export function selectableMethods(
|
|
166
|
+
offered: PaymentMethod[] | null,
|
|
136
167
|
cardUnavailable: boolean,
|
|
168
|
+
): PaymentMethod[] {
|
|
169
|
+
return (offered ?? ["PIX", "CARD"]).filter(
|
|
170
|
+
(method) => !(method === "CARD" && cardUnavailable),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
|
|
176
|
+
* FUT-741).
|
|
177
|
+
*
|
|
178
|
+
* A one-option radiogroup that still demands a tap is a click that buys the
|
|
179
|
+
* buyer nothing. It used to fire only when the CARD tile was disabled, which
|
|
180
|
+
* missed the other shape entirely: a store whose chain declares PIX alone
|
|
181
|
+
* renders one tile, nothing preselects it, and the buyer sits on a picker with
|
|
182
|
+
* a single option and no payment below it.
|
|
183
|
+
*/
|
|
184
|
+
export function usePreselectSoleMethod(
|
|
185
|
+
selectable: readonly PaymentMethod[],
|
|
137
186
|
method: PaymentMethod | null,
|
|
138
187
|
onMethodChange: (method: PaymentMethod) => void,
|
|
139
188
|
): void {
|
|
189
|
+
const sole = selectable.length === 1 ? selectable[0] : undefined;
|
|
140
190
|
useEffect(() => {
|
|
141
|
-
if (
|
|
142
|
-
}, [
|
|
191
|
+
if (method === null && sole) onMethodChange(sole);
|
|
192
|
+
}, [sole, method, onMethodChange]);
|
|
143
193
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE a hosted handover sends the buyer (FUT-741).
|
|
3
|
+
*
|
|
4
|
+
* The handover is a full navigation to ANOTHER ORIGIN — a redirect provider's
|
|
5
|
+
* page, or a 3-DS challenge — so it can never be the host's router. But it is
|
|
6
|
+
* still a thing some hosts must observe: log it, confirm it, or render an
|
|
7
|
+
* interstitial around it. Left as a bare `window.location.assign` inside the
|
|
8
|
+
* controller it was none of those, and a browser that blocks or stalls the
|
|
9
|
+
* navigation left the buyer on a page with nothing on it.
|
|
10
|
+
*
|
|
11
|
+
* So it is a port with a default. No provider ⇒ exactly today's behaviour.
|
|
12
|
+
*/
|
|
13
|
+
import { createContext, useContext, type JSX, type ReactNode } from "react";
|
|
14
|
+
|
|
15
|
+
/** Take the buyer to another origin. */
|
|
16
|
+
export type CheckoutNavigate = (url: string) => void;
|
|
17
|
+
|
|
18
|
+
/** The unchanged default: a full navigation, in this tab. */
|
|
19
|
+
const DEFAULT_NAVIGATE: CheckoutNavigate = (url) => {
|
|
20
|
+
window.location.assign(url);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const CheckoutNavigateContext = createContext<CheckoutNavigate | null>(null);
|
|
24
|
+
|
|
25
|
+
export function CheckoutNavigateProvider({
|
|
26
|
+
navigate,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
navigate: CheckoutNavigate;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}): JSX.Element {
|
|
32
|
+
return (
|
|
33
|
+
<CheckoutNavigateContext.Provider value={navigate}>{children}</CheckoutNavigateContext.Provider>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The host's navigate port, or `window.location.assign`. */
|
|
38
|
+
export function useCheckoutNavigate(): CheckoutNavigate {
|
|
39
|
+
return useContext(CheckoutNavigateContext) ?? DEFAULT_NAVIGATE;
|
|
40
|
+
}
|