@12-apps/payments-frontend 1.7.0 → 1.8.0
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 +28 -6
- 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
|
}
|
|
@@ -128,16 +128,38 @@ export function offeredMethods(config: CheckoutProviderConfig | null): PaymentMe
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* The methods a buyer can actually pick right now.
|
|
132
|
+
*
|
|
133
|
+
* `null` offered — still loading, or a fetch blip — fails OPEN and offers both;
|
|
134
|
+
* the server still refuses the charge closed. A CARD tile with no in-browser
|
|
135
|
+
* path is not selectable, so it does not count towards a "sole" method either.
|
|
134
136
|
*/
|
|
135
|
-
export function
|
|
137
|
+
export function selectableMethods(
|
|
138
|
+
offered: PaymentMethod[] | null,
|
|
136
139
|
cardUnavailable: boolean,
|
|
140
|
+
): PaymentMethod[] {
|
|
141
|
+
return (offered ?? ["PIX", "CARD"]).filter(
|
|
142
|
+
(method) => !(method === "CARD" && cardUnavailable),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
|
|
148
|
+
* FUT-741).
|
|
149
|
+
*
|
|
150
|
+
* A one-option radiogroup that still demands a tap is a click that buys the
|
|
151
|
+
* buyer nothing. It used to fire only when the CARD tile was disabled, which
|
|
152
|
+
* missed the other shape entirely: a store whose chain declares PIX alone
|
|
153
|
+
* renders one tile, nothing preselects it, and the buyer sits on a picker with
|
|
154
|
+
* a single option and no payment below it.
|
|
155
|
+
*/
|
|
156
|
+
export function usePreselectSoleMethod(
|
|
157
|
+
selectable: readonly PaymentMethod[],
|
|
137
158
|
method: PaymentMethod | null,
|
|
138
159
|
onMethodChange: (method: PaymentMethod) => void,
|
|
139
160
|
): void {
|
|
161
|
+
const sole = selectable.length === 1 ? selectable[0] : undefined;
|
|
140
162
|
useEffect(() => {
|
|
141
|
-
if (
|
|
142
|
-
}, [
|
|
163
|
+
if (method === null && sole) onMethodChange(sole);
|
|
164
|
+
}, [sole, method, onMethodChange]);
|
|
143
165
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkout's HTTP transport, as a bound client (FUT-741).
|
|
3
|
+
*
|
|
4
|
+
* Everything `client.ts` used to do with a hard-coded prefix and the ambient
|
|
5
|
+
* `fetch` now lives here behind {@link createCheckoutClient}, so the same five
|
|
6
|
+
* calls can be pointed at a different mount, carry a host's auth headers, or —
|
|
7
|
+
* the reason this exists — be driven through an injected `fetch` that routes
|
|
8
|
+
* straight into a real `createPaymentFlowsBE` mount. A story or a harness page
|
|
9
|
+
* can then exercise the WIRE rather than a mock of our own client, which is the
|
|
10
|
+
* only place the FUT-740 review found its three criticals.
|
|
11
|
+
*
|
|
12
|
+
* `baseUrl` defaults to `/api/checkout` VERBATIM — not normalized, not
|
|
13
|
+
* re-derived. The published client already posts those exact paths, and both
|
|
14
|
+
* pinned wire suites drive them; a prefix that is "cleaned up" here breaks the
|
|
15
|
+
* shipped contract in the same release that introduces the factory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SavedCard } from "../../card";
|
|
19
|
+
import { err, ok, type Result } from "../../result";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
ChargeCardInput,
|
|
23
|
+
ChargeOutcome,
|
|
24
|
+
CheckoutProviderConfig,
|
|
25
|
+
OrderStatus,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The prefix every shipped buyer checkout posts to today. Exported so a host
|
|
30
|
+
* (or a test) can state it rather than re-type it, and so a change to it is a
|
|
31
|
+
* change to one named constant with this comment attached.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
|
|
34
|
+
|
|
35
|
+
/** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
|
|
36
|
+
export interface CheckoutTransport {
|
|
37
|
+
/** Prefix for `/config`, `/status`, `/charge`, `/cards`, `/refresh-key`. */
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
|
|
41
|
+
* suite that stubs the global after the client was built still sees its stub.
|
|
42
|
+
*/
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
/** Extra headers per request (a bearer token, a tenant header). */
|
|
45
|
+
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
|
|
49
|
+
export interface CheckoutClient {
|
|
50
|
+
getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
|
|
51
|
+
getStatus(ref: string): Promise<Result<OrderStatus>>;
|
|
52
|
+
charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
|
|
53
|
+
listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
|
|
54
|
+
refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Envelope the API routes return: `{ data }` on success, `{ error }` on failure. */
|
|
58
|
+
interface ApiEnvelope<T> {
|
|
59
|
+
data?: T;
|
|
60
|
+
error?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Stable machine code for the failure (`checkoutErrorResponse` always sends
|
|
63
|
+
* one). Carried through so a surface can PRESENT a refusal for what it is —
|
|
64
|
+
* an unresolved charge is not a decline, and rendering it under "não foi
|
|
65
|
+
* possível pagar" with a live pay button invites the second payment its own
|
|
66
|
+
* text forbids.
|
|
67
|
+
*/
|
|
68
|
+
code?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A non-2xx envelope as a {@link Result} failure, carrying its machine CODE.
|
|
73
|
+
* The message is what the buyer reads; the code is what a surface uses to
|
|
74
|
+
* decide how to PRESENT it, which a message cannot be parsed for.
|
|
75
|
+
*/
|
|
76
|
+
function refused<T>(json: ApiEnvelope<T> | null): Result<T> {
|
|
77
|
+
return err(json?.error ?? "Não foi possível concluir a operação. Tente novamente.", json?.code);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a hosted checkout appended to the return URL when it sent the buyer
|
|
82
|
+
* back, or undefined.
|
|
83
|
+
*
|
|
84
|
+
* InfinitePay's `payment_check` refuses to confirm without BOTH the
|
|
85
|
+
* `transaction_nsu` and the invoice `slug`, and neither exists until somebody
|
|
86
|
+
* has actually paid — they arrive here, on the redirect. Left in the URL: the
|
|
87
|
+
* server treats them as hints, so re-sending them on later polls is harmless.
|
|
88
|
+
*
|
|
89
|
+
* Note the buyer has to press "Continuar" on the provider's receipt for this
|
|
90
|
+
* redirect to happen at all, which is exactly why it is a hint and never the
|
|
91
|
+
* mechanism: the webhook, and the server-side reconciliation behind it, are
|
|
92
|
+
* what must work when they simply close the tab.
|
|
93
|
+
*/
|
|
94
|
+
function returnedSettlement(): Record<string, string> {
|
|
95
|
+
if (typeof window === "undefined") return {};
|
|
96
|
+
const params = new URLSearchParams(window.location.search);
|
|
97
|
+
// BOTH. Measured against the live API: handle + order_nsu +
|
|
98
|
+
// transaction_nsu + slug answers {"success":true,"paid":true,…}, and the
|
|
99
|
+
// same call missing EITHER answers {"success":false}. Neither exists before
|
|
100
|
+
// the payment, and the slug is not in the link-creation response — the
|
|
101
|
+
// redirect and the webhook are the only places both appear together.
|
|
102
|
+
const transactionNsu = params.get("transaction_nsu") ?? params.get("transaction_id") ?? "";
|
|
103
|
+
const slug = params.get("slug") ?? "";
|
|
104
|
+
return {
|
|
105
|
+
...(transactionNsu ? { transactionNsu } : {}),
|
|
106
|
+
...(slug ? { slug } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The ambient `fetch`, wrapped so it is never invoked detached from its global. */
|
|
111
|
+
function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
112
|
+
return globalThis.fetch(input, init);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The five checkout calls, bound to one transport.
|
|
117
|
+
*
|
|
118
|
+
* Passing no transport reproduces exactly what the free functions in
|
|
119
|
+
* `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
|
|
120
|
+
*/
|
|
121
|
+
export function createCheckoutClient(transport: CheckoutTransport = {}): CheckoutClient {
|
|
122
|
+
const baseUrl = transport.baseUrl ?? DEFAULT_CHECKOUT_BASE_URL;
|
|
123
|
+
|
|
124
|
+
/** Call a checkout route and normalize the response into a {@link Result}. */
|
|
125
|
+
async function call<T>(route: string, init?: RequestInit): Promise<Result<T>> {
|
|
126
|
+
// Resolved here, not captured at build time: a suite (or a story) that
|
|
127
|
+
// replaces the global afterwards must still be the one that answers.
|
|
128
|
+
const doFetch = transport.fetchImpl ?? ambientFetch;
|
|
129
|
+
try {
|
|
130
|
+
const extra = transport.headers ? await transport.headers() : undefined;
|
|
131
|
+
const res = await doFetch(`${baseUrl}${route}`, {
|
|
132
|
+
...init,
|
|
133
|
+
headers: { "Content-Type": "application/json", ...extra, ...init?.headers },
|
|
134
|
+
});
|
|
135
|
+
const json = (await res.json().catch(() => null)) as ApiEnvelope<T> | null;
|
|
136
|
+
if (!res.ok) return refused(json);
|
|
137
|
+
if (!json || json.data === undefined) {
|
|
138
|
+
return err(json?.error ?? "Resposta inválida do servidor.");
|
|
139
|
+
}
|
|
140
|
+
return ok(json.data);
|
|
141
|
+
} catch {
|
|
142
|
+
return err("Não foi possível conectar. Verifique sua conexão e tente novamente.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
getConfig: (tenantSlug) =>
|
|
148
|
+
call<CheckoutProviderConfig>(`/config?tenantSlug=${encodeURIComponent(tenantSlug)}`, {
|
|
149
|
+
method: "GET",
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
getStatus: (ref) =>
|
|
153
|
+
call<OrderStatus>(
|
|
154
|
+
`/status?${new URLSearchParams({ orderId: ref, ...returnedSettlement() }).toString()}`,
|
|
155
|
+
{ method: "GET" },
|
|
156
|
+
),
|
|
157
|
+
|
|
158
|
+
charge: (input) =>
|
|
159
|
+
call<ChargeOutcome>("/charge", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
// The FLAT body the shipped client has always sent. Pinned from both
|
|
162
|
+
// ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
orderId: input.orderId,
|
|
165
|
+
token: input.token,
|
|
166
|
+
// One instrument per provider (FUT-563) — the server hands each
|
|
167
|
+
// provider in the chain its own, which is what lets a card charge
|
|
168
|
+
// fail over.
|
|
169
|
+
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
170
|
+
saveCard: input.saveCard,
|
|
171
|
+
cardMeta: input.cardMeta,
|
|
172
|
+
taxId: input.taxId,
|
|
173
|
+
}),
|
|
174
|
+
}),
|
|
175
|
+
|
|
176
|
+
listInstruments: async (tenantSlug) => {
|
|
177
|
+
// Scoped to the store when known (FUT-697): only cards the store's ACTIVE
|
|
178
|
+
// provider can actually charge come back — a PagBank-vaulted card is not
|
|
179
|
+
// offered for a Stone charge it would fail (or misroute).
|
|
180
|
+
const query = tenantSlug ? `?tenantSlug=${encodeURIComponent(tenantSlug)}` : "";
|
|
181
|
+
const result = await call<SavedCard[]>(`/cards${query}`, { method: "GET" });
|
|
182
|
+
return result.ok ? result.data : [];
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
refreshBrowserKey: (input) =>
|
|
186
|
+
call<{ publicKey: string | null }>("/refresh-key", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: JSON.stringify({ orderId: input.orderId }),
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
}
|