@12-apps/payments-frontend 3.21.4 → 3.23.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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +387 -0
  6. package/src/components/checkout/checkout-flow.tsx +126 -24
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/confirmation-wait.ts +97 -0
  11. package/src/components/checkout/dados-step.tsx +141 -0
  12. package/src/components/checkout/decline.ts +48 -0
  13. package/src/components/checkout/en-US.ts +41 -0
  14. package/src/components/checkout/failure-codes.ts +82 -0
  15. package/src/components/checkout/hosted-return.ts +190 -206
  16. package/src/components/checkout/hosted-store.ts +291 -0
  17. package/src/components/checkout/payment-error-panel.tsx +9 -3
  18. package/src/components/checkout/payment-status-parts.tsx +311 -0
  19. package/src/components/checkout/payment-status.tsx +69 -264
  20. package/src/components/checkout/pix-view.tsx +97 -8
  21. package/src/components/checkout/poll-loop.ts +5 -3
  22. package/src/components/checkout/providers/types.ts +20 -3
  23. package/src/components/checkout/pt-BR.ts +42 -0
  24. package/src/components/checkout/screens-copy.ts +14 -0
  25. package/src/components/checkout/screens-en-US.ts +1 -0
  26. package/src/components/checkout/screens-pt-BR.ts +3 -0
  27. package/src/components/checkout/transport.ts +21 -1
  28. package/src/components/checkout/types.ts +35 -0
  29. package/src/components/checkout/use-card-checkout.ts +34 -33
  30. package/src/components/checkout/use-checkout-controller.ts +68 -274
  31. package/src/components/checkout/use-hosted-resume.ts +326 -0
  32. package/src/components/checkout/use-payment-polling.ts +58 -7
  33. package/src/components/checkout/use-wallet-charge.ts +24 -1
  34. package/src/components/checkout/view-copy.ts +70 -0
  35. package/src/components/checkout/wallet-pane.tsx +9 -1
  36. package/src/flows/catalog-exit.ts +33 -0
  37. package/src/flows/create-payment-flows.tsx +19 -1
  38. package/src/flows/pipeline/actions.tsx +104 -0
  39. package/src/flows/pipeline/admission.ts +55 -0
  40. package/src/flows/pipeline/context.ts +140 -0
  41. package/src/flows/pipeline/derive-step.ts +234 -0
  42. package/src/flows/pipeline/engine-actions.ts +257 -0
  43. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  44. package/src/flows/pipeline/engine-state.ts +107 -0
  45. package/src/flows/pipeline/engine.tsx +377 -0
  46. package/src/flows/pipeline/methods.ts +71 -0
  47. package/src/flows/pipeline/refusal-routing.ts +106 -0
  48. package/src/flows/pipeline/slices.ts +110 -0
  49. package/src/flows/pipeline/stable-plugins.ts +72 -0
  50. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  51. package/src/flows/pipeline/steps/index.ts +54 -0
  52. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  53. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  54. package/src/flows/pipeline/types.ts +232 -0
  55. package/src/flows/public.ts +78 -0
  56. package/src/flows/screens-hosted.tsx +55 -5
  57. package/src/flows/screens-pay.tsx +6 -1
  58. package/src/flows/types.ts +25 -2
  59. package/src/index.ts +29 -19
@@ -0,0 +1,387 @@
1
+ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
2
+
3
+ import { buyerGateError } from "./buyer-gate";
4
+ import type { ConfirmationWait } from "./confirmation-wait";
5
+ import type { CheckoutDecline } from "./decline";
6
+ import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
7
+ import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
8
+ import type { CheckoutNavigate } from "./navigate-context";
9
+ import type { CheckoutScreensCopy } from "./screens-copy";
10
+ import type {
11
+ BuyerContact,
12
+ BuyerField,
13
+ BuyerInfo,
14
+ CheckoutCustomerField,
15
+ CheckoutOrder,
16
+ CreateOrderRequest,
17
+ CreateOrderResult,
18
+ OrderStatus,
19
+ PaymentMethod,
20
+ } from "./types";
21
+ import { useResumeEffect, type HostedResume } from "./use-hosted-resume";
22
+
23
+ /**
24
+ * The controller's own moving parts, one concern per hook.
25
+ *
26
+ * Split out of `./use-checkout-controller.ts` when the resumed leg grew a
27
+ * decision (FUT-1213), a general parked entry (FUT-1140) and a way out
28
+ * (FUT-1146) and that file's one exported function reached its size gate. The
29
+ * seam is the obvious one: everything here is state a step reads, and nothing
30
+ * here knows the ORDER the steps come in.
31
+ */
32
+
33
+ /** Where the flow can be — the step ids, which are the flow's own contract. */
34
+ export type Step = "dados" | "payment" | "status";
35
+
36
+ /**
37
+ * The flow's navigation actions, split out of {@link useCheckoutController} for
38
+ * the 80-line per-function gate.
39
+ *
40
+ * `back` is where "the Dados step was skipped" has to be honoured: going back
41
+ * off Pagamento normally lands on Dados, but for a buyer with a CPF on file
42
+ * that step is not part of their flow, so the menu is the only honest
43
+ * destination — until they open it themselves via `editBuyer` ("alterar" on the
44
+ * payer block), after which it IS part of their flow and back returns to it.
45
+ */
46
+ export function useCheckoutNav(
47
+ taxIdOnFile: boolean,
48
+ goToMenu: () => void,
49
+ setStep: Dispatch<SetStateAction<Step>>,
50
+ ): { back: () => void; editBuyer: (() => void) | undefined } {
51
+ const [dadosOpened, setDadosOpened] = useState(false);
52
+ const openDados = useCallback(() => {
53
+ setDadosOpened(true);
54
+ setStep("dados");
55
+ }, [setStep]);
56
+ const back = useCallback(() => {
57
+ setStep((current) => {
58
+ if (current === "payment" && (!taxIdOnFile || dadosOpened)) return "dados";
59
+ goToMenu();
60
+ return current;
61
+ });
62
+ }, [dadosOpened, goToMenu, setStep, taxIdOnFile]);
63
+ // Undefined unless Dados was skipped — the payer block keys off its presence,
64
+ // so the decision lives here rather than being re-derived by every caller.
65
+ return { back, editBuyer: taxIdOnFile ? openDados : undefined };
66
+ }
67
+
68
+ /**
69
+ * WHAT THE CONFIRMATION SCREEN KNOWS ABOUT ITS WAIT, from whichever wait is
70
+ * live.
71
+ *
72
+ * Two can reach that screen and they are mutually exclusive by construction: a
73
+ * checkout RESUMED from a parked entry polls through `useHostedResume`, and one
74
+ * that never left this tab polls through the confirmation wait (FUT-1170) — and
75
+ * that one stands down whenever a resumed order is what put the flow here. So
76
+ * the merge below is a choice between one live wait and one inert one, never a
77
+ * blend of two opinions.
78
+ *
79
+ * All of it is inert for a checkout with an answer already: with nothing to
80
+ * wait on both polls are disabled, so the error stays null, no bound elapses,
81
+ * and none of the actions has a wait to act on.
82
+ *
83
+ * `release` stays the resumed leg's alone. "Não consegui pagar" exists because
84
+ * a provider's own page produces no signal when a buyer abandons it (FUT-1146);
85
+ * a charge raised and still held on THIS page has no such gap to close.
86
+ */
87
+ export function resumeSurface(
88
+ resume: HostedResume,
89
+ confirming: ConfirmationWait,
90
+ ): {
91
+ awaitingTimedOut: boolean;
92
+ awaitingError: string | null;
93
+ awaitingCheckAgain: () => void;
94
+ /** @deprecated Renamed to `awaitingTimedOut`; kept so the rename ships additively. */
95
+ resumeTimedOut: boolean;
96
+ /** @deprecated Renamed to `awaitingError`; kept so the rename ships additively. */
97
+ resumeError: string | null;
98
+ /** @deprecated Renamed to `awaitingCheckAgain`; kept so the rename ships additively. */
99
+ resumeCheckAgain: () => void;
100
+ resumeRelease: (() => void) | undefined;
101
+ resumeReleasing: boolean;
102
+ } {
103
+ const live = resume.order === null ? confirming : resume;
104
+ return {
105
+ awaitingTimedOut: live.timedOut,
106
+ awaitingError: live.error,
107
+ awaitingCheckAgain: live.checkAgain,
108
+ // The three old spellings, beside the new ones rather than replaced by them.
109
+ // This shape is PUBLIC — `CheckoutController` is `ReturnType<typeof
110
+ // useCheckoutController>` (flows/types.ts:148), re-exported from index.ts,
111
+ // and `PaymentFlows.useCheckout()` returns it. Dropping three members of it
112
+ // is a source break for any adopter that hand-composes the flow, and this
113
+ // release is a patch. The rename was cosmetic; breaking a published type for
114
+ // it is not a trade worth making, so the old names stay until a major.
115
+ resumeTimedOut: live.timedOut,
116
+ resumeError: live.error,
117
+ resumeCheckAgain: live.checkAgain,
118
+ resumeRelease: resume.release,
119
+ resumeReleasing: resume.releasing,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Open the flow on whatever was resumed, and close it again if the buyer
125
+ * releases the order (FUT-1140/FUT-1146).
126
+ *
127
+ * A LAYOUT effect, through `useResumeEffect`: the decision cannot be made
128
+ * during render (it waits for the host's cart), and an ordinary effect runs
129
+ * after paint — so a buyer coming back from a payment would see one frame of
130
+ * the Dados or Pagamento step before their confirmation replaced it.
131
+ */
132
+ export function useResumedCheckout(
133
+ resume: HostedResume,
134
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
135
+ setStep: Dispatch<SetStateAction<Step>>,
136
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>,
137
+ setMethod: Dispatch<SetStateAction<PaymentMethod | null>>,
138
+ ): void {
139
+ const { order, step, released } = resume;
140
+ useResumeEffect(() => {
141
+ if (!order || !step) return;
142
+ setOrder(order);
143
+ setStep(step);
144
+ // The METHOD comes back with it, which matters for a resume that lands on
145
+ // the payment step: the picker keys off it, and a shopper looking at the
146
+ // PIX code they were already paying must not also be asked to choose how
147
+ // to pay. Set through the raw setter on purpose — the public `setMethod`
148
+ // drops the order on a change, which is the opposite of resuming one.
149
+ setMethod(order.method);
150
+ }, [order, step, setOrder, setStep, setMethod]);
151
+ useResumeEffect(() => {
152
+ if (!released) return;
153
+ // Back to a checkout they can actually use, with the basket they are
154
+ // holding. The order they released is gone from this screen; whether it is
155
+ // gone at the provider is the server's answer, not ours.
156
+ setOrder(null);
157
+ setFinalStatus(null);
158
+ setStep("payment");
159
+ }, [released, setOrder, setStep, setFinalStatus]);
160
+ }
161
+
162
+ /**
163
+ * The end of a checkout: tell the host, and let the parked entry go.
164
+ *
165
+ * PAID fires the host's `onPaid` port. FUT-601 made the SERVER empty the cart
166
+ * inside the confirmation transaction — but nothing told the SPA, whose cart
167
+ * provider survives every checkout route change and kept counting the items the
168
+ * buyer had just bought. A FAILED or EXPIRED order fires nothing: that shopper
169
+ * still has a basket to retry with, and the host must not be told otherwise.
170
+ *
171
+ * The parked entry is dropped on ANY terminal status (FUT-1140). It exists to
172
+ * carry one payment across a torn-down SPA; once that payment has an answer,
173
+ * resuming it could only re-show an outcome the buyer has already been given.
174
+ */
175
+ export function useSettledPort(settled: OrderStatus | null, onPaid: (() => void) | undefined): void {
176
+ useEffect(() => {
177
+ if (!settled || settled === "AWAITING_PAYMENT") return;
178
+ forgetHostedOrder();
179
+ if (settled === "PAID") onPaid?.();
180
+ }, [settled, onPaid]);
181
+ }
182
+
183
+ /**
184
+ * The create-order refusal the steps render: what to say, which field to
185
+ * highlight, and the machine CODE that decides how it is presented — an
186
+ * unresolved charge is not a failed one, and the Pagamento step must not offer
187
+ * it a "Tentar novamente" (FUT-563). One hook so the three always move
188
+ * together; they were three `useState`s that could be cleared apart.
189
+ */
190
+ export function useCreateFailure() {
191
+ const [message, setMessage] = useState<string | null>(null);
192
+ const [field, setField] = useState<BuyerField | null>(null);
193
+ const [code, setCode] = useState<string | null>(null);
194
+ const clear = useCallback(() => {
195
+ setMessage(null);
196
+ setField(null);
197
+ setCode(null);
198
+ }, []);
199
+ const fail = useCallback(
200
+ (next: { message: string; field?: BuyerField | null; code?: string }) => {
201
+ setMessage(next.message);
202
+ setField(next.field ?? null);
203
+ setCode(next.code ?? null);
204
+ },
205
+ [],
206
+ );
207
+ return { message, field, code, clear, fail };
208
+ }
209
+
210
+
211
+ /**
212
+ * "Continuar" on the Dados step: gate on what the store's chain demands, then
213
+ * PERSIST the buyer's details before advancing.
214
+ *
215
+ * The write happens HERE and not when a payment is raised, because everything
216
+ * after this step can fail — no provider configured, a declined card, an
217
+ * abandoned PIX, a closed tab — and the details must survive all of it.
218
+ * Fire-and-forget on purpose: making the buyer wait on the write, or blocking
219
+ * them when it fails, would trade the bug for a worse one. Gated on the
220
+ * "salvar meus dados" consent (LGPD), which is what the checkbox means.
221
+ */
222
+ export function useGoToPayment(input: {
223
+ buyer: BuyerInfo;
224
+ buyerFields: readonly CheckoutCustomerField[];
225
+ taxIdOnFile: boolean;
226
+ saveProfile: boolean;
227
+ saveBuyerContact: ((contact: BuyerContact) => void) | undefined;
228
+ validation: CheckoutScreensCopy["validation"];
229
+ failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null }) => void };
230
+ setStep: Dispatch<SetStateAction<Step>>;
231
+ }): () => void {
232
+ const { buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact } = input;
233
+ const { validation, failure, setStep } = input;
234
+ return useCallback(() => {
235
+ failure.clear();
236
+ const complaint = buyerGateError(validation, buyer, buyerFields, taxIdOnFile);
237
+ if (complaint) {
238
+ failure.fail(complaint);
239
+ return;
240
+ }
241
+ if (saveProfile) {
242
+ saveBuyerContact?.({ name: buyer.name, phone: buyer.phone, taxId: buyer.taxId });
243
+ }
244
+ setStep("payment");
245
+ }, [buyer, buyerFields, saveProfile, taxIdOnFile, failure, saveBuyerContact, validation, setStep]);
246
+ }
247
+
248
+ /**
249
+ * "Tentar novamente" after a refusal — and WHICH order it goes against.
250
+ *
251
+ * A RETRIABLE decline keeps the order (FUT-1145). Minting a new one for every
252
+ * refused card leaves a trail of failed orders in the buyer's own history for
253
+ * one purchase they are still trying to make, and the money rule is unchanged
254
+ * either way: the server refuses a second charge on a payable it has already
255
+ * settled. `freshInstrument` is the other half — the saved card that just
256
+ * failed is not chosen for them again.
257
+ */
258
+ export function useRetryAction(input: {
259
+ decline: CheckoutDecline | null;
260
+ order: CheckoutOrder | null;
261
+ clearError: () => void;
262
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
263
+ setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
264
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
265
+ setStep: Dispatch<SetStateAction<Step>>;
266
+ setFreshInstrument: Dispatch<SetStateAction<boolean>>;
267
+ }): () => void {
268
+ const { decline, order, clearError, setOrder, setDecline } = input;
269
+ const { setFinalStatus, setStep, setFreshInstrument } = input;
270
+ return useCallback(() => {
271
+ setFinalStatus(null);
272
+ clearError();
273
+ setStep("payment");
274
+ if (decline?.retriable && order) {
275
+ setFreshInstrument(true);
276
+ return;
277
+ }
278
+ setOrder(null);
279
+ setDecline(null);
280
+ forgetHostedOrder();
281
+ }, [
282
+ clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
283
+ ]);
284
+ }
285
+
286
+ /**
287
+ * Hand the buyer to a redirect provider's own page, if that is where this
288
+ * charge settles (FUT-556).
289
+ *
290
+ * Called BEFORE the order is stored: storing it first would render the PIX or
291
+ * card step for a provider that returned neither, which is the dead end this
292
+ * fixes.
293
+ *
294
+ * A full navigation rather than the host's router, because the destination is
295
+ * another origin. The return trip comes back to this same checkout route
296
+ * carrying `transaction_nsu` + `slug`, which the status poll already reads.
297
+ *
298
+ * @returns true when the buyer is on their way and the caller must stop.
299
+ */
300
+ function handOverToProvider(
301
+ order: CheckoutOrder,
302
+ navigate: CheckoutNavigate,
303
+ tenantSlug?: string,
304
+ basket?: CheckoutBasketIdentity,
305
+ ): boolean {
306
+ if (!order.hostedCheckoutUrl) return false;
307
+ // PARK FIRST, navigate second. The order is the only thing the return trip
308
+ // has to rehydrate from, and the navigation may tear this SPA down before
309
+ // any later write lands.
310
+ //
311
+ // The STORE goes with it: one tab holds one slot, and on a multi-tenant
312
+ // storefront every store shares an origin. Without the slug, abandoning this
313
+ // hand-off and opening another store's checkout resumed THIS order there.
314
+ //
315
+ // So does the BASKET (FUT-1213): a hand-off nobody completed must not resume
316
+ // itself over the shopper's next basket, and the only way to tell the two
317
+ // apart later is to record which basket this one was raised from.
318
+ rememberHostedOrder(order, {
319
+ tenantSlug,
320
+ basket: parkedBasket(basket),
321
+ handoff: true,
322
+ });
323
+ navigate(order.hostedCheckoutUrl);
324
+ return true;
325
+ }
326
+
327
+ /**
328
+ * Raise the order for a chosen method, and decide what happens to it.
329
+ *
330
+ * Three outcomes, in order: a refusal the step renders, a HAND-OFF that leaves
331
+ * this page for the provider's own, and an order raised here.
332
+ */
333
+ export function useStartPayment(input: {
334
+ buyer: BuyerInfo;
335
+ saveProfile: boolean;
336
+ createOrder: (request: CreateOrderRequest) => Promise<CreateOrderResult>;
337
+ navigate: CheckoutNavigate;
338
+ tenantSlug: string | undefined;
339
+ basket: CheckoutBasketIdentity | undefined;
340
+ failure: { clear: () => void; fail: (next: { message: string; field?: BuyerField | null; code?: string }) => void };
341
+ setCreating: Dispatch<SetStateAction<boolean>>;
342
+ setDecline: Dispatch<SetStateAction<CheckoutDecline | null>>;
343
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>;
344
+ setFinalStatus: Dispatch<SetStateAction<OrderStatus | null>>;
345
+ }): (chosen: PaymentMethod, override?: BuyerInfo) => Promise<void> {
346
+ const { buyer, saveProfile, createOrder, navigate, tenantSlug, basket, failure } = input;
347
+ const { setCreating, setDecline, setOrder, setFinalStatus } = input;
348
+ return useCallback(
349
+ async (chosen: PaymentMethod, override?: BuyerInfo) => {
350
+ failure.clear();
351
+ setDecline(null);
352
+ // THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
353
+ // rather than after it. Raising a payment means whatever was on screen is
354
+ // no longer the one being paid, and a provider round trip is long enough
355
+ // for the difference to matter: "Gerar novo código" left the expired
356
+ // charge mounted, so its own view polled it, got the terminal EXPIRED it
357
+ // was always going to get, and bounced the flow to the confirmation
358
+ // screen — where the new charge then landed with nothing polling it.
359
+ //
360
+ // A no-op on every other caller (the auto-raise, the alternate e-mail and
361
+ // the retry all run with no order held), which is the point: the clear
362
+ // belongs to what raising a payment MEANS, not to the one path that
363
+ // noticed.
364
+ setOrder(null);
365
+ setFinalStatus(null);
366
+ setCreating(true);
367
+ const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
368
+ setCreating(false);
369
+ if (!result.ok) {
370
+ failure.fail(result.error);
371
+ return;
372
+ }
373
+ if (handOverToProvider(result.data, navigate, tenantSlug, basket)) return;
374
+ // PARKED EVEN THOUGH NOBODY IS LEAVING (FUT-1140). A low-memory phone
375
+ // discards this tab while the shopper is in their bank app, and the SPA
376
+ // that comes back has never heard of the order it raised — so the buyer
377
+ // meets an empty cart and a retry button instead of the confirmation for
378
+ // the payment they just made.
379
+ rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
380
+ setOrder(result.data);
381
+ },
382
+ [
383
+ buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
384
+ setCreating, setDecline, setOrder, setFinalStatus,
385
+ ],
386
+ );
387
+ }
@@ -1,8 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
2
  import { useMemo, type JSX, type ReactNode } from "react";
3
3
 
4
+ import type { CheckoutBasketIdentity } from "./basket";
4
5
  import { buyerFieldsFor } from "./buyer-fields";
5
- import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
6
+ import { EmptyCart, PaymentStep } from "./checkout-steps";
7
+ import { DadosStep } from "./dados-step";
6
8
  import { ArrowBackIcon } from "./icons";
7
9
  import { PaymentStatus } from "./payment-status";
8
10
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
@@ -29,6 +31,18 @@ export interface CheckoutCartView {
29
31
  totalItems: number;
30
32
  /** Host-rendered discount itemization under the pay-bar total (FUT-246). */
31
33
  discountLines?: ReactNode;
34
+ /**
35
+ * WHICH basket this is, so a payment raised from another one cannot resume
36
+ * itself over it (FUT-1213).
37
+ *
38
+ * Optional, and absent means the pre-1213 behaviour — a parked payment
39
+ * resumes on whatever checkout mounts next, which is what shipped and what
40
+ * this ticket exists to bound. A host supplies it by calling
41
+ * `basketSignature(lines)` on its own cart and passing `ready: false` while
42
+ * that cart is still loading; see `./basket.ts` for why the identity is the
43
+ * LINES and not the cart's id.
44
+ */
45
+ identity?: CheckoutBasketIdentity;
32
46
  }
33
47
 
34
48
  /**
@@ -84,6 +98,16 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
84
98
  components?: Partial<CheckoutComponents>;
85
99
  }
86
100
 
101
+ /**
102
+ * Whether the host's cart has actually answered yet.
103
+ *
104
+ * `true` for every host that wires no identity, which is what it meant before
105
+ * there was one to wire.
106
+ */
107
+ function cartLoaded(cart: CheckoutCartView): boolean {
108
+ return cart.identity?.ready !== false;
109
+ }
110
+
87
111
  /** The pay-bar total override when settling a settlement (else the cart's own totals). */
88
112
  function settlementTotalOverride(
89
113
  settlement: SettlementCheckout | null | undefined,
@@ -173,12 +197,101 @@ function StatusStep({
173
197
  onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
174
198
  onBackToMenu={c.goToMenu}
175
199
  paidExtra={confirmationExtra}
176
- awaitingTimedOut={c.resumeTimedOut}
177
- // The resumed leg's own trouble, and the way out of it (FUT-1144). Both
178
- // are inert for a checkout that never left this tab nothing was parked,
179
- // so nothing is being polled here.
180
- awaitingError={c.resumeError}
181
- onCheckAgain={c.resumeCheckAgain}
200
+ awaitingTimedOut={c.awaitingTimedOut}
201
+ // How the wait behind this screen is going, and the way out of it
202
+ // (FUT-1144). It is the resumed leg's wait for a checkout that came back
203
+ // from a provider's page, and this step's own (FUT-1170) for one that
204
+ // never left the tab — which used to have no wait at all, and so a
205
+ // spinner that stood for nothing.
206
+ awaitingError={c.awaitingError}
207
+ onCheckAgain={c.awaitingCheckAgain}
208
+ // The buyer's own way out of a hosted wait with no terminal state
209
+ // (FUT-1146). Absent — and so unrendered — until the resumed leg has one
210
+ // to offer, which is every checkout that never left this tab.
211
+ onNotPaid={c.resumeRelease}
212
+ releasing={c.resumeReleasing}
213
+ // WHY a card was refused, when the server said (FUT-1145). Read on
214
+ // FAILED only, where it picks the sentence and decides whether a retry
215
+ // could work at all.
216
+ decline={c.decline}
217
+ />
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Nothing to check out — and the cart has ANSWERED, which is the half FUT-1213
223
+ * added.
224
+ *
225
+ * A settlement pays already-sent items, so the cart is legitimately empty
226
+ * there. The guard cannot key on the Dados step either: skipping it (FUT-465)
227
+ * makes Pagamento the first screen. And it holds until an order exists — once
228
+ * one does, its lines are snapshotted server-side and the cart no longer speaks
229
+ * for it.
230
+ *
231
+ * A cart still being FETCHED is empty in exactly the way a real one is not, so
232
+ * a host that wires `identity` gets this screen when its cart is empty rather
233
+ * than when it is late — which is also the moment the resume decision waits for.
234
+ *
235
+ * A RAISE IN FLIGHT is not "nothing to pay for" either (FUT-1170). Raising a
236
+ * charge now drops the one it replaces before it asks for the new one, so there
237
+ * is a window with no order held — and a checkout whose cart the host had
238
+ * already emptied would otherwise swap the buyer onto the empty-cart screen
239
+ * mid-regenerate, discarding the charge as it arrived.
240
+ */
241
+ function nothingToPayFor(
242
+ settlement: SettlementCheckout | null | undefined,
243
+ cart: CheckoutCartView,
244
+ c: ReturnType<typeof useCheckoutController>,
245
+ ): boolean {
246
+ if (settlement || !cart.empty || !cartLoaded(cart)) return false;
247
+ return !c.order && !c.creating && c.step !== "status";
248
+ }
249
+
250
+ /** Step 2, with the controller's facts and the host's money mapped onto it. */
251
+ function PagamentoStep({
252
+ c,
253
+ cart,
254
+ settlement,
255
+ providerConfig,
256
+ tenantSlug,
257
+ validateApplePayMerchant,
258
+ }: {
259
+ c: ReturnType<typeof useCheckoutController>;
260
+ cart: CheckoutCartView;
261
+ settlement: SettlementCheckout | null | undefined;
262
+ providerConfig: CheckoutProviderConfig | null | undefined;
263
+ tenantSlug: string | undefined;
264
+ validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
265
+ }): JSX.Element {
266
+ return (
267
+ <PaymentStep
268
+ method={c.method}
269
+ onMethodChange={c.setMethod}
270
+ order={c.order}
271
+ buyer={c.buyer}
272
+ creating={c.creating}
273
+ createError={c.createError}
274
+ errorField={c.errorField}
275
+ errorCode={c.errorCode}
276
+ onGenerate={(chosen) => void c.startPayment(chosen)}
277
+ onUseEmail={c.payWithEmail}
278
+ // Set only for a skipped-Dados flow (the controller decides); the payer
279
+ // block hides itself when it is absent.
280
+ onEditBuyer={c.editBuyer}
281
+ providerConfig={providerConfig}
282
+ tenantSlug={tenantSlug}
283
+ // The amount, on the step that asks for it (FUT-1179).
284
+ cartTotals={cart}
285
+ totalOverride={settlementTotalOverride(settlement)}
286
+ discountLines={cart.discountLines}
287
+ // Retrying a refused card: the saved card that failed is not chosen for
288
+ // them again (FUT-1145).
289
+ freshInstrument={c.freshInstrument}
290
+ // The card path parks an order of its own for a 3-D Secure challenge, so
291
+ // it needs the same basket the flow was mounted for (FUT-1213).
292
+ basket={cart.identity}
293
+ validateApplePayMerchant={validateApplePayMerchant}
294
+ onResolved={c.handleResolved}
182
295
  />
183
296
  );
184
297
  }
@@ -190,7 +303,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
190
303
  // any chain member may need rather than re-opening after the choice. A chain
191
304
  // that declares nothing degrades to CPF-required, never to "ask nothing".
192
305
  const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
193
- const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
306
+ const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug, cart.identity);
194
307
  const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
195
308
 
196
309
  // A settlement settlement pays already-sent kitchen items — the cart is
@@ -200,7 +313,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
200
313
  // Pagamento the first screen, so an empty cart would otherwise reach the
201
314
  // method picker. It holds until an order exists — once one does, its lines are
202
315
  // snapshotted server-side and the cart no longer speaks for it.
203
- if (!settlement && cart.empty && !c.order && c.step !== "status") {
316
+ if (nothingToPayFor(settlement, cart, c)) {
204
317
  return <EmptyCart copy={copy.emptyCart} onBack={c.goToMenu} />;
205
318
  }
206
319
 
@@ -229,24 +342,13 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
229
342
 
230
343
  {c.step === "payment" ? (
231
344
  <OneClickProvider armed={armed}>
232
- <PaymentStep
233
- method={c.method}
234
- onMethodChange={c.setMethod}
235
- order={c.order}
236
- buyer={c.buyer}
237
- creating={c.creating}
238
- createError={c.createError}
239
- errorField={c.errorField}
240
- errorCode={c.errorCode}
241
- onGenerate={(chosen) => void c.startPayment(chosen)}
242
- onUseEmail={c.payWithEmail}
243
- // Set only for a skipped-Dados flow (the controller decides); the
244
- // payer block hides itself when it is absent.
245
- onEditBuyer={c.editBuyer}
345
+ <PagamentoStep
346
+ c={c}
347
+ cart={cart}
348
+ settlement={settlement}
246
349
  providerConfig={providerConfig}
247
350
  tenantSlug={tenantSlug}
248
351
  validateApplePayMerchant={validateApplePayMerchant}
249
- onResolved={c.handleResolved}
250
352
  />
251
353
  </OneClickProvider>
252
354
  ) : null}