@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.
@@ -198,6 +198,17 @@ export interface ChargeCardInput {
198
198
  taxId?: string;
199
199
  }
200
200
 
201
+ /** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
202
+ export interface CheckoutCustomerField {
203
+ key: "name" | "email" | "taxId" | "phone";
204
+ /** Which validation rule applies. `MOBILE` is the strictly narrower `PHONE`. */
205
+ type: "NAME" | "EMAIL" | "PHONE" | "MOBILE" | "CPF";
206
+ /** The provider refuses (or its own page stalls) without this field. */
207
+ required: boolean;
208
+ /** Methods this spec applies to; omitted means every method the adapter takes. */
209
+ methods?: ("PIX" | "CARD" | "BOLETO")[];
210
+ }
211
+
201
212
  /**
202
213
  * ONE enabled provider of the store's chain (FUT-563), as the buyer's config
203
214
  * answers it. The same protocol facts as the head, stated per provider —
@@ -209,6 +220,22 @@ export interface CheckoutChainLink {
209
220
  publicKey: string | null;
210
221
  mockTokenization: boolean;
211
222
  methods: ("PIX" | "CARD" | "BOLETO")[];
223
+ /**
224
+ * What this provider needs to know about the buyer (FUT-595).
225
+ *
226
+ * `GET /api/checkout/config` has published this since FUT-740; this mirror
227
+ * type simply dropped it, so the buyer form could not be honest about what
228
+ * the chain requires and a missing field was only discoverable as a 400 AFTER
229
+ * the buyer had finished filling it in — the third FUT-740 critical, one
230
+ * layer up.
231
+ *
232
+ * Optional, and the DEGRADE DIRECTION IS LOAD-BEARING: absent (an older host,
233
+ * a hand-written config) must mean today's behaviour — CPF required — never
234
+ * "this chain asks for nothing". Asking for nothing produces a form the buyer
235
+ * completes and a charge the server then refuses for a document they were
236
+ * never shown a field for.
237
+ */
238
+ customerSchema?: CheckoutCustomerField[];
212
239
  }
213
240
 
214
241
  /**
@@ -145,7 +145,18 @@ const CheckoutComponentsContext = createContext<CheckoutComponents>(defaultCheck
145
145
  /**
146
146
  * Fills the checkout's component slots for everything rendered beneath it.
147
147
  * Partial on purpose: a host overrides only the slots its design system
148
- * covers and inherits the raw-MUI default for the rest.
148
+ * covers and inherits the rest.
149
+ *
150
+ * Inherits from the NEAREST provider above, not from the raw-MUI defaults.
151
+ * That distinction is the whole contract once these nest — and they do nest:
152
+ * `createPaymentFlows` fills the slots once at the factory, and the
153
+ * `CheckoutFlow` inside its mount opens a provider of its own. Merging over
154
+ * the defaults there re-imposed raw MUI on a host that had already stated its
155
+ * design system, silently, one level in. Merging over the inherited set makes
156
+ * a nested provider ADD to what is above it, which is what "partial" has to
157
+ * mean for the surface to agree with itself. The context's own default is
158
+ * still `defaultCheckoutComponents`, so the outermost provider is unchanged:
159
+ * an unfilled slot is raw MUI exactly as before.
149
160
  */
150
161
  export function CheckoutComponentsProvider({
151
162
  components,
@@ -154,9 +165,10 @@ export function CheckoutComponentsProvider({
154
165
  components?: Partial<CheckoutComponents>;
155
166
  children: ReactNode;
156
167
  }): JSX.Element {
168
+ const inherited = useCheckoutComponents();
157
169
  const value = useMemo<CheckoutComponents>(
158
- () => ({ ...defaultCheckoutComponents, ...components }),
159
- [components],
170
+ () => ({ ...inherited, ...components }),
171
+ [inherited, components],
160
172
  );
161
173
  return (
162
174
  <CheckoutComponentsContext.Provider value={value}>
@@ -17,13 +17,14 @@ import {
17
17
  } from "../../card";
18
18
  import { ok, type Result } from "../../result";
19
19
 
20
- import { resolveNewCardToken, type CardInstruments } from "./card-instruments";
21
20
  import {
22
- chargeCard,
23
- listSavedCards,
24
- refreshCardPublicKey,
25
- } from "./client";
21
+ resolveNewCardToken,
22
+ type CardInstruments,
23
+ type RefreshBrowserKey,
24
+ } from "./card-instruments";
25
+ import { useCheckoutClientApi } from "./client-context";
26
26
  import { rememberHostedOrder } from "./hosted-return";
27
+ import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
27
28
  import type { CardChainLink } from "./method-capability";
28
29
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
29
30
  import { usePaymentPolling } from "./use-payment-polling";
@@ -43,10 +44,11 @@ function useSavedCards(tenantSlug: string | undefined): {
43
44
  } {
44
45
  const [savedCards, setSavedCards] = useState<SavedCard[]>([]);
45
46
  const [selection, setSelection] = useState<string>(NEW_CARD);
47
+ const client = useCheckoutClientApi();
46
48
 
47
49
  useEffect(() => {
48
50
  let active = true;
49
- void listSavedCards(tenantSlug).then((cards) => {
51
+ void client.listInstruments(tenantSlug).then((cards) => {
50
52
  if (!active) return;
51
53
  setSavedCards(cards);
52
54
  const first = cards[0];
@@ -55,7 +57,7 @@ function useSavedCards(tenantSlug: string | undefined): {
55
57
  return () => {
56
58
  active = false;
57
59
  };
58
- }, [tenantSlug]);
60
+ }, [tenantSlug, client]);
59
61
 
60
62
  return { savedCards, selection, setSelection };
61
63
  }
@@ -76,19 +78,20 @@ function useCardPublicKey(
76
78
  setPublicKey: (key: string) => void;
77
79
  } {
78
80
  const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
81
+ const client = useCheckoutClientApi();
79
82
  const refreshable =
80
83
  config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
81
84
 
82
85
  useEffect(() => {
83
86
  if (config.publicKey !== null || !refreshable) return undefined;
84
87
  let active = true;
85
- void refreshCardPublicKey({ orderId }).then((result) => {
88
+ void client.refreshBrowserKey({ orderId }).then((result) => {
86
89
  if (active && result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
87
90
  });
88
91
  return () => {
89
92
  active = false;
90
93
  };
91
- }, [orderId, config.publicKey, refreshable]);
94
+ }, [orderId, config.publicKey, refreshable, client]);
92
95
 
93
96
  return { publicKey, setPublicKey };
94
97
  }
@@ -151,9 +154,16 @@ const CARD_AWAITING_POLL_CAP = 36;
151
154
  * redirect provider's link takes (FUT-556): the return lands back on this
152
155
  * checkout route, where the hosted-resume machinery polls the parked order.
153
156
  */
154
- function handOverToChallenge(order: CheckoutOrder, url: string): void {
157
+ function handOverToChallenge(
158
+ order: CheckoutOrder,
159
+ url: string,
160
+ navigate: CheckoutNavigate,
161
+ ): void {
162
+ // PARK FIRST. The navigation may not come back to a live SPA at all, and a
163
+ // return trip that finds nothing parked lands the buyer on a blank
164
+ // confirmation after they have paid.
155
165
  rememberHostedOrder(order);
156
- window.location.assign(url);
166
+ navigate(url);
157
167
  }
158
168
 
159
169
  /** The buyer's card is invalid — block the submit and show which field. */
@@ -178,6 +188,8 @@ async function resolveInstruments(input: {
178
188
  config: CardTokenizationConfig;
179
189
  onKeyRefreshed: (key: string) => void;
180
190
  providerChain: readonly CardChainLink[];
191
+ /** The bound key-refresh call (FUT-741) — the self-heal must hit OUR mount. */
192
+ refreshKey: RefreshBrowserKey;
181
193
  }): Promise<Result<CardInstruments>> {
182
194
  const { form } = input;
183
195
  if (!form.usingNewCard) return ok({ token: form.selection });
@@ -188,6 +200,8 @@ async function resolveInstruments(input: {
188
200
  input.onKeyRefreshed,
189
201
  form.saveCard,
190
202
  input.providerChain,
203
+ undefined,
204
+ input.refreshKey,
191
205
  );
192
206
  }
193
207
 
@@ -212,6 +226,8 @@ function useCardSubmit(
212
226
  // The active key: resolved by the checkout config, overridden by the
213
227
  // rotated-key self-heal for the rest of the session.
214
228
  const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
229
+ const client = useCheckoutClientApi();
230
+ const navigate = useCheckoutNavigate();
215
231
 
216
232
  const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
217
233
  enabled: submitted,
@@ -235,6 +251,7 @@ function useCardSubmit(
235
251
  config: { ...providerConfig, publicKey },
236
252
  onKeyRefreshed: setPublicKey,
237
253
  providerChain,
254
+ refreshKey: client.refreshBrowserKey,
238
255
  });
239
256
  if (!resolved.ok) {
240
257
  setError(resolved.error);
@@ -242,7 +259,7 @@ function useCardSubmit(
242
259
  return;
243
260
  }
244
261
 
245
- const charged = await chargeCard({
262
+ const charged = await client.charge({
246
263
  orderId: order.orderId,
247
264
  token: resolved.data.token,
248
265
  tokensByProvider: resolved.data.tokensByProvider,
@@ -261,7 +278,7 @@ function useCardSubmit(
261
278
  // 3-D Secure (FUT-698): the buyer must finish on the provider's page.
262
279
  // `submitting` stays true on purpose — the tab is navigating away.
263
280
  if (charged.data.hostedCheckoutUrl) {
264
- return handOverToChallenge(order, charged.data.hostedCheckoutUrl);
281
+ return handOverToChallenge(order, charged.data.hostedCheckoutUrl, navigate);
265
282
  }
266
283
  // A business outcome (e.g. declined → FAILED) shows the status screen;
267
284
  // an accepted charge begins polling for the async confirmation.
@@ -1,14 +1,16 @@
1
1
  import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
2
2
 
3
- import { validateCpf } from "../../card";
3
+ import { buyerFormComplete } from "./buyer-info-form";
4
4
 
5
5
  import { rememberHostedOrder, takeHostedOrder } from "./hosted-return";
6
+ import { useCheckoutNavigate, type CheckoutNavigate } from "./navigate-context";
6
7
  import type {
7
8
  BuyerContact,
8
9
  BuyerField,
9
10
  BuyerInfo,
10
11
  CheckoutOrder,
11
12
  CreateOrderRequest,
13
+ CheckoutCustomerField,
12
14
  CreateOrderResult,
13
15
  OrderStatus,
14
16
  PaymentMethod,
@@ -19,6 +21,11 @@ type Step = "dados" | "payment" | "status";
19
21
 
20
22
  const STEP_ORDER: Step[] = ["dados", "payment", "status"];
21
23
 
24
+ /** The pre-FUT-595 demand, and the safe reading of a chain that declared none. */
25
+ const CPF_ONLY: readonly CheckoutCustomerField[] = [
26
+ { key: "taxId", type: "CPF", required: true },
27
+ ];
28
+
22
29
  /**
23
30
  * Everything the flow needs FROM its host, as explicit ports (FUT-564). The
24
31
  * package owns the payment surface; the host owns the cart, the catalog,
@@ -81,19 +88,49 @@ function useCheckoutNav(
81
88
  return { back, editBuyer: taxIdOnFile ? openDados : undefined };
82
89
  }
83
90
 
91
+ /** The message shown when a declared field is missing or malformed. */
92
+ const FIELD_COMPLAINT: Record<string, string> = {
93
+ taxId: "CPF inválido.",
94
+ name: "Informe seu nome.",
95
+ email: "E-mail inválido.",
96
+ phone: "Telefone inválido.",
97
+ };
98
+
99
+ /** Which input to highlight for a declared key. */
100
+ const FIELD_INPUT: Record<string, BuyerField> = {
101
+ taxId: "cpf",
102
+ name: "name",
103
+ email: "email",
104
+ phone: "phone",
105
+ };
106
+
84
107
  /**
85
- * What the "Continuar" gate objects to about the CPF, or undefined to advance.
108
+ * What the "Continuar" gate objects to, or undefined to advance.
86
109
  *
87
- * A blank field is only an error when the store has NO CPF for this buyer. With
110
+ * The fields come from the chain's own declaration (FUT-595) absent, they
111
+ * degrade to CPF-required, which is exactly what this gate has always demanded.
112
+ *
113
+ * A blank CPF is only an error when the store has NO CPF for this buyer. With
88
114
  * one on file the field starts empty by design (the client is never sent the
89
115
  * saved CPF), so demanding one here trapped a returning buyer who opened Dados
90
116
  * through "Alterar" and changed their mind: they could not reach Pagamento
91
117
  * again, and back only led out to the menu. Leaving it blank means "charge me
92
118
  * as before", which is exactly what the server's `resolveBuyerTaxId` does.
93
119
  */
94
- function cpfGateError(typed: string, taxIdOnFile: boolean): string | undefined {
95
- if (!typed && taxIdOnFile) return undefined;
96
- return validateCpf(typed);
120
+ function buyerGateError(
121
+ buyer: BuyerInfo,
122
+ fields: readonly CheckoutCustomerField[],
123
+ taxIdOnFile: boolean,
124
+ ): { message: string; field: BuyerField } | undefined {
125
+ const effective = taxIdOnFile
126
+ ? fields.filter((field) => !(field.key === "taxId" && !buyer.taxId?.trim()))
127
+ : fields;
128
+ const offending = buyerFormComplete(buyer, effective);
129
+ if (!offending) return undefined;
130
+ return {
131
+ message: FIELD_COMPLAINT[offending.key] ?? "Campo obrigatório.",
132
+ field: FIELD_INPUT[offending.key] ?? "cpf",
133
+ };
97
134
  }
98
135
 
99
136
  /**
@@ -119,10 +156,13 @@ function initialStep(resuming: boolean, taxIdOnFile: boolean): Step {
119
156
  *
120
157
  * @returns true when the buyer is on their way and the caller must stop.
121
158
  */
122
- function handOverToProvider(order: CheckoutOrder): boolean {
159
+ function handOverToProvider(order: CheckoutOrder, navigate: CheckoutNavigate): boolean {
123
160
  if (!order.hostedCheckoutUrl) return false;
161
+ // PARK FIRST, navigate second. The order is the only thing the return trip
162
+ // has to rehydrate from, and the navigation may tear this SPA down before
163
+ // any later write lands.
124
164
  rememberHostedOrder(order);
125
- window.location.assign(order.hostedCheckoutUrl);
165
+ navigate(order.hostedCheckoutUrl);
126
166
  return true;
127
167
  }
128
168
 
@@ -197,20 +237,24 @@ function useCreateFailure() {
197
237
  * being saved.
198
238
  * - `startPayment` raises the order (PIX → QR, CARD → chargeable); `payWithEmail`
199
239
  * re-raises with a corrected e-mail for the merchant-email rejection.
240
+ *
241
+ * @param taxIdOnFile The buyer already has a CPF saved (FUT-465) ⇒ the Dados
242
+ * step has nothing left to ask, so checkout opens on Pagamento and `back`
243
+ * goes to the menu rather than to a form the buyer never saw. The CPF itself
244
+ * is never in the client's hands — the server reads it from the encrypted
245
+ * profile when the charge is raised.
246
+ * @param buyerFields What the store's chain declares it needs from the buyer
247
+ * (FUT-595). Absent ⇒ CPF-required, which is what this gate demanded before
248
+ * there was a declaration to read — never "ask nothing".
200
249
  */
201
250
  export function useCheckoutController(
202
251
  ports: CheckoutHostPorts,
203
252
  defaultBuyer?: BuyerInfo,
204
- /**
205
- * The buyer already has a CPF saved (FUT-465) ⇒ the Dados step has nothing
206
- * left to ask, so checkout opens on Pagamento and `back` goes to the menu
207
- * rather than to a form the buyer never saw. The CPF itself is never in the
208
- * client's hands — the server reads it from the encrypted profile when the
209
- * charge is raised.
210
- */
211
253
  taxIdOnFile = false,
254
+ buyerFields: readonly CheckoutCustomerField[] = CPF_ONLY,
212
255
  ) {
213
256
  const { createOrder, saveBuyerContact, onExitToMenu, onPaid } = ports;
257
+ const navigate = useCheckoutNavigate();
214
258
  const resume = useHostedResume();
215
259
  const [step, setStep] = useState<Step>(initialStep(Boolean(resume.order), taxIdOnFile));
216
260
  // No method pre-selected: the Pagamento step shows just the picker until the
@@ -232,8 +276,8 @@ export function useCheckoutController(
232
276
  }, [clearError]);
233
277
  const goToPayment = useCallback(() => {
234
278
  clearError();
235
- const cpfError = cpfGateError(buyer.taxId?.trim() ?? "", taxIdOnFile);
236
- if (cpfError) { failure.fail({ message: cpfError, field: "cpf" }); return; }
279
+ const complaint = buyerGateError(buyer, buyerFields, taxIdOnFile);
280
+ if (complaint) { failure.fail(complaint); return; }
237
281
  // Persist the buyer's details HERE, on "Continuar" — not when a payment is
238
282
  // raised. Everything after this step can fail (no provider configured, a
239
283
  // declined card, an abandoned PIX, a closed tab) and the details must
@@ -245,17 +289,17 @@ export function useCheckoutController(
245
289
  saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
246
290
  }
247
291
  setStep("payment");
248
- }, [buyer.name, buyer.phone, buyer.taxId, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
292
+ }, [buyer, buyerFields, saveProfile, taxIdOnFile, clearError, saveBuyerContact]);
249
293
  const startPayment = useCallback(async (chosen: PaymentMethod, override?: BuyerInfo) => {
250
294
  clearError();
251
295
  setCreating(true);
252
296
  const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
253
297
  setCreating(false);
254
298
  if (!result.ok) { failure.fail(result.error); return; }
255
- if (handOverToProvider(result.data)) return;
299
+ if (handOverToProvider(result.data, navigate)) return;
256
300
  setOrder(result.data);
257
301
  setFinalStatus(null);
258
- }, [buyer, saveProfile, createOrder, clearError]);
302
+ }, [buyer, saveProfile, createOrder, clearError, navigate]);
259
303
  const payWithEmail = useCallback((email: string) => {
260
304
  if (!method) return;
261
305
  const next = { ...buyer, email };
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
 
3
- import { pollOrderStatus } from "./client";
3
+ import { useCheckoutClientApi } from "./client-context";
4
4
  import { TERMINAL_STATUSES, type OrderStatus } from "./types";
5
5
 
6
6
  interface PollingOptions {
@@ -34,6 +34,10 @@ export function usePaymentPolling(
34
34
  const [status, setStatus] = useState<OrderStatus | null>(null);
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const [timedOut, setTimedOut] = useState(false);
37
+ // Whichever mount this tree is bound to (FUT-741). Stable per provider, so it
38
+ // belongs in the deps below rather than being read out of a ref: a checkout
39
+ // re-pointed at another mount must re-poll against THAT one.
40
+ const client = useCheckoutClientApi();
37
41
 
38
42
  useEffect(() => {
39
43
  if (!orderId || !enabled) {
@@ -48,7 +52,7 @@ export function usePaymentPolling(
48
52
  setError(null);
49
53
 
50
54
  const tick = async (): Promise<void> => {
51
- const result = await pollOrderStatus(orderId);
55
+ const result = await client.getStatus(orderId);
52
56
  if (cancelled) {
53
57
  return;
54
58
  }
@@ -87,7 +91,7 @@ export function usePaymentPolling(
87
91
  clearTimeout(timer);
88
92
  }
89
93
  };
90
- }, [orderId, intervalMs, enabled, maxHealthyPolls]);
94
+ }, [orderId, intervalMs, enabled, maxHealthyPolls, client]);
91
95
 
92
96
  return { status, error, timedOut };
93
97
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The sentences `createPaymentFlows` renders on its OWN screens (FUT-741).
3
+ *
4
+ * Scope is deliberately narrow and stated rather than implied: this covers the
5
+ * copy the FACTORY owns — the unavailable screen's two remedies, the hosted
6
+ * handover and its fallback link, the empty cart, and the buyer form's continue
7
+ * action. The screens that already carried their own product copy before this
8
+ * ticket (PIX, card, status) keep it; moving all of it here in the same change
9
+ * that introduces the factory would be a copy rewrite disguised as an API.
10
+ *
11
+ * Every default below is today's pt-BR, verbatim — a host that passes no `copy`
12
+ * reads exactly what a buyer reads now.
13
+ */
14
+
15
+ /** Every buyer-facing string the factory's own screens render. */
16
+ export interface CheckoutCopyFE {
17
+ /** No provider connected AND no host remedy: the store simply does not charge. */
18
+ unavailableTitle: string;
19
+ unavailableBody: string;
20
+ /** No provider connected but the host offers a remedy (this repo: the waiter). */
21
+ unavailableWithRemedyTitle: string;
22
+ unavailableWithRemedyBody: string;
23
+ /** The hosted handover interstitial, and the link that is its fallback. */
24
+ handoffTitle: string;
25
+ handoffBody: string;
26
+ handoffLink: string;
27
+ handoffCancel: string;
28
+ /** The return leg, while the parked order is polled to a terminal state. */
29
+ returnPending: string;
30
+ /** The return leg when nothing was parked — the buyer came back to nothing. */
31
+ returnUnknown: string;
32
+ /** Nothing to check out (cart mode only). */
33
+ emptyCartTitle: string;
34
+ emptyCartAction: string;
35
+ /** The Dados step's primary action. */
36
+ continueAction: string;
37
+ }
38
+
39
+ export const DEFAULT_CHECKOUT_COPY_FE: CheckoutCopyFE = {
40
+ unavailableTitle: "Pagamento online indisponível",
41
+ unavailableBody:
42
+ "Esta loja não recebe pagamentos pelo site. Combine o pagamento diretamente com a loja para concluir seu pedido.",
43
+ unavailableWithRemedyTitle: "Pagamento com o garçom",
44
+ unavailableWithRemedyBody:
45
+ "Esta loja não recebe pagamentos pelo site. Chame o garçom para fechar a conta na mesa.",
46
+ handoffTitle: "Você será levado ao pagamento",
47
+ handoffBody:
48
+ "Estamos abrindo a página segura do meio de pagamento. Se ela não abrir sozinha, use o link abaixo.",
49
+ handoffLink: "Abrir a página de pagamento",
50
+ handoffCancel: "Voltar",
51
+ returnPending: "Confirmando seu pagamento…",
52
+ returnUnknown:
53
+ "Não encontramos um pagamento em andamento nesta sessão. Verifique seus pedidos em instantes.",
54
+ emptyCartTitle: "Seu carrinho está vazio.",
55
+ emptyCartAction: "Ver cardápio",
56
+ continueAction: "Continuar",
57
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `createPaymentFlows` — the buyer checkout, mounted rather than composed
3
+ * (FUT-741).
4
+ *
5
+ * Called ONCE at module scope; the host mounts what comes back. See
6
+ * `./types.ts` for the config vocabulary and why none of it names a vendor.
7
+ *
8
+ * ## Why the scope arrives as HOOKS
9
+ *
10
+ * `useScope`, `useCart`, `useBuyerDefaults`, `useComanda` and
11
+ * `ports.useAvailability` are hooks, not values, and they are invoked in a
12
+ * component BODY — never read at factory time. The factory runs once, at module
13
+ * evaluation, so a value-shaped config would freeze the first store's slug and
14
+ * one moment's cart total onto every checkout the page ever renders. Naming
15
+ * them `use*` also keeps rules-of-hooks lint able to see them.
16
+ */
17
+ import { useCallback, type JSX, type ReactNode } from "react";
18
+
19
+ import { buyerFieldsFor } from "../components/checkout/buyer-fields";
20
+ import { CheckoutFlow } from "../components/checkout/checkout-flow";
21
+ import { createCheckoutClient } from "../components/checkout/transport";
22
+ import type { CheckoutProviderConfig, ComandaCheckout } from "../components/checkout/types";
23
+ import { useCheckoutController } from "../components/checkout/use-checkout-controller";
24
+
25
+ import { DEFAULT_CHECKOUT_COPY_FE } from "./copy";
26
+ import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
27
+ import { buyerScreens } from "./screens-buyer";
28
+ import { hostedScreens } from "./screens-hosted";
29
+ import { payScreens, storeCannotCharge } from "./screens-pay";
30
+ import type {
31
+ CheckoutAvailability,
32
+ CheckoutController,
33
+ CheckoutScreens,
34
+ PaymentFlows,
35
+ PaymentFlowsConfig,
36
+ } from "./types";
37
+
38
+ /** A store that is always payable — what a host with no veto to cast means. */
39
+ const ALWAYS_PAYABLE: CheckoutAvailability = { payable: true };
40
+
41
+ /** Build the runtime every screen closes over. */
42
+ function buildRuntime(config: PaymentFlowsConfig): FlowsRuntime {
43
+ const client = createCheckoutClient(config.transport);
44
+ const navigate =
45
+ config.ports.navigate ??
46
+ ((url: string) => {
47
+ window.location.assign(url);
48
+ });
49
+ return {
50
+ config,
51
+ client,
52
+ copy: { ...DEFAULT_CHECKOUT_COPY_FE, ...config.copy },
53
+ navigate,
54
+ // Both of these are HOOKS. They are called from a component body on every
55
+ // render, so the slug follows the host's router and the availability vote
56
+ // follows whatever the host currently knows.
57
+ useTenantSlug: () => config.useScope?.().tenantSlug,
58
+ useAvailability: () => config.ports.useAvailability?.() ?? ALWAYS_PAYABLE,
59
+ };
60
+ }
61
+
62
+ /** The whole three-step flow, with the config fetched and availability decided. */
63
+ function buildCheckout(
64
+ runtime: FlowsRuntime,
65
+ screens: CheckoutScreens,
66
+ ): PaymentFlows["Checkout"] {
67
+ const { ports } = runtime.config;
68
+ const Unavailable = screens.PaymentsUnavailable;
69
+
70
+ function CheckoutBody({ comanda }: { comanda?: ComandaCheckout | null }): JSX.Element {
71
+ const cart = runtime.config.useCart();
72
+ const defaults = runtime.config.useBuyerDefaults?.() ?? {};
73
+ const hostComanda = runtime.config.useComanda?.() ?? null;
74
+ const { config, pending } = useResolvedConfig(runtime);
75
+ const availability = runtime.useAvailability();
76
+ const tenantSlug = runtime.useTenantSlug();
77
+ // Adapts the port to the package's older, prop-shaped contract. Memoised so
78
+ // the controller does not see a fresh `createOrder` identity each render.
79
+ const createOrder = useCallback(
80
+ (input: Parameters<typeof ports.createPayable>[0]) => ports.createPayable(input),
81
+ [],
82
+ );
83
+
84
+ if (storeCannotCharge(config, pending, availability.payable)) return <Unavailable />;
85
+
86
+ return (
87
+ <CheckoutFlow
88
+ cart={cart}
89
+ createOrder={createOrder}
90
+ saveBuyerContact={ports.saveBuyerContact}
91
+ onExitToMenu={ports.exitToCatalog}
92
+ onPaid={ports.onPaid}
93
+ defaultBuyer={defaults.buyer}
94
+ taxIdOnFile={defaults.taxIdOnFile ?? false}
95
+ comanda={comanda ?? hostComanda}
96
+ providerConfig={config}
97
+ tenantSlug={tenantSlug}
98
+ confirmationExtra={runtime.config.confirmation?.extra}
99
+ />
100
+ );
101
+ }
102
+
103
+ return function Checkout(props) {
104
+ // Its OWN provider, so the one-line mount really is one line: `/config` is
105
+ // fetched here and every nested screen reads that answer.
106
+ //
107
+ // The design-system slots come from the `FlowsShell` inside it, and the
108
+ // `CheckoutComponentsProvider` that `CheckoutFlow` opens one level down
109
+ // INHERITS them (see `ui.tsx`) rather than resetting to raw MUI — which is
110
+ // why nothing here re-threads `components`.
111
+ return (
112
+ <FlowsProvider runtime={runtime}>
113
+ <CheckoutBody {...props} />
114
+ </FlowsProvider>
115
+ );
116
+ };
117
+ }
118
+
119
+ /** Assemble the eleven screens from their builders. */
120
+ function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
121
+ return {
122
+ MethodChoice: buyerScreens.buildMethodChoice(runtime),
123
+ BuyerDetails: buyerScreens.buildBuyerDetails(runtime),
124
+ CardEntry: payScreens.buildCardEntry(runtime),
125
+ PixPayment: payScreens.buildPixPayment(runtime),
126
+ HostedHandoff: hostedScreens.buildHostedHandoff(runtime),
127
+ HostedReturn: hostedScreens.buildHostedReturn(runtime),
128
+ PaymentStatus: payScreens.buildPaymentStatus(runtime),
129
+ PaymentsUnavailable: payScreens.buildPaymentsUnavailable(runtime),
130
+ PayerSummary: buyerScreens.buildPayerSummary(runtime),
131
+ SavedCards: buyerScreens.buildSavedCards(runtime),
132
+ EmptyCart: buyerScreens.buildEmptyCart(runtime),
133
+ };
134
+ }
135
+
136
+ /** The flow controller, pre-bound to the ports and the chain's declaration. */
137
+ function buildUseCheckout(runtime: FlowsRuntime): () => CheckoutController {
138
+ const { ports } = runtime.config;
139
+ return function useCheckout(): CheckoutController {
140
+ const { config } = useResolvedConfig(runtime);
141
+ const defaults = runtime.config.useBuyerDefaults?.() ?? {};
142
+ return useCheckoutController(
143
+ {
144
+ createOrder: ports.createPayable,
145
+ saveBuyerContact: ports.saveBuyerContact,
146
+ onExitToMenu: ports.exitToCatalog,
147
+ onPaid: ports.onPaid,
148
+ },
149
+ defaults.buyer,
150
+ defaults.taxIdOnFile ?? false,
151
+ // Resolved for NO method: the gate runs on the Dados step, before the
152
+ // picker, and FUT-595's rule is to collect the union up front.
153
+ buyerFieldsFor(config?.chain, null),
154
+ );
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The buyer checkout, pre-bound.
160
+ *
161
+ * ```ts
162
+ * export const components = createPaymentFlows({ useCart, ports: { … } });
163
+ * // …and in the page: <components.Checkout />
164
+ * ```
165
+ *
166
+ * The flat exports (`CheckoutFlow`, the hooks, the fetch clients) are
167
+ * unchanged and remain the escape hatch for a host that wants its own
168
+ * composition — nothing here replaces them.
169
+ */
170
+ export function createPaymentFlows(config: PaymentFlowsConfig): PaymentFlows {
171
+ const runtime = buildRuntime(config);
172
+ const screens = buildScreens(runtime);
173
+
174
+ function Provider({
175
+ children,
176
+ config: provided,
177
+ }: {
178
+ children: ReactNode;
179
+ config?: CheckoutProviderConfig | null;
180
+ }): JSX.Element {
181
+ return (
182
+ <FlowsProvider runtime={runtime} config={provided}>
183
+ {children}
184
+ </FlowsProvider>
185
+ );
186
+ }
187
+
188
+ return {
189
+ Checkout: buildCheckout(runtime, screens),
190
+ Provider,
191
+ screens,
192
+ useCheckout: buildUseCheckout(runtime),
193
+ useCheckoutConfig: () => useResolvedConfig(runtime),
194
+ client: runtime.client,
195
+ };
196
+ }