@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.
@@ -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 { ActionBar, Alert, Button, Checkbox, Text } = useCheckoutComponents();
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
- <ActionBar dataTestId="checkout-pay-bar">
200
- <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
201
- {createError ? (
202
- <Alert variant="danger" title="Não foi possível continuar" description={createError} showIcon data-testid="checkout-error" />
203
- ) : null}
204
- <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
205
- <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>
206
- {/* Suppressed while settling a comanda: those totals come from
207
- the frozen ticket, not the cart. */}
208
- {totalOverride ? null : discountLines}
209
- </PayBarTotal>
210
- <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
211
- <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
212
- Continuar
213
- </Button>
214
- <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
215
- <LockOutlinedIcon sx={{ fontSize: 13 }} />
216
- <Text variant="caption" size="xs" color="secondary" as="span">
217
- Pagamento seguro
218
- </Text>
219
- </Box>
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
- </ActionBar>
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={offeredMethods(providerConfig ?? null)}
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 { err, ok, type Result } from "../../result";
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
- * What a hosted checkout appended to the return URL when it sent the buyer
77
- * back, or undefined.
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
- function returnedSettlement(): Record<string, string> {
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
- const params = new URLSearchParams({ orderId, ...returnedSettlement() });
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 requestResult<CheckoutProviderConfig>(
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 requestResult<{ publicKey: string | null }>("/api/checkout/refresh-key", {
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 requestResult<ChargeOutcome>("/api/checkout/charge", {
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
- // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
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 = cardChain(config);
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((link) => ({
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
- * When the card path is unavailable, PIX is the only choice left — choose it
132
- * (FUT-697 review): a one-option radiogroup that still demands a tap is a
133
- * click that buys the buyer nothing.
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 usePreselectSoleMethod(
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 (cardUnavailable && method === null) onMethodChange("PIX");
142
- }, [cardUnavailable, method, onMethodChange]);
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
+ }