@12-apps/payments-frontend 3.48.1 → 3.49.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.48.1",
3
+ "version": "3.49.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -155,6 +155,9 @@ export function ChoiceList({
155
155
  const base = useId();
156
156
  const props: RowProps = { ...rest, name: `${base}-choice` };
157
157
  const { copy, state } = props;
158
+ // Nothing to tap before the saved cards answer: a row tapped now would move
159
+ // when the saved card is preselected and opens its chips (FUT-3849).
160
+ if (!state.settled) return <Box data-testid="payment-list-loading" aria-busy="true" sx={{ minHeight: (theme) => theme.spacing(30) }} />;
158
161
  return (
159
162
  <Box
160
163
  data-testid="payment-list"
@@ -4,7 +4,6 @@ import { useEffect, useRef, type JSX } from "react";
4
4
  import { formatCpf, validateCpf } from "../../../card";
5
5
  import { fieldSatisfied } from "../buyer-fields";
6
6
  import { useCheckoutCopy } from "../copy-context";
7
- import { useCheckoutLayout } from "../payment-step-layout";
8
7
  import type { BuyerInfo, CheckoutCustomerField } from "../types";
9
8
  import { useCheckoutComponents } from "../ui";
10
9
 
@@ -15,6 +14,14 @@ import type { PaymentListCopy } from "./types";
15
14
  /** The prototype's 280px CPF field on a computer (L7), on the theme's spacing scale. */
16
15
  const FIELD_WIDE_MAX = 35;
17
16
 
17
+ /**
18
+ * The list's own width from which the field stops at ~280px (FUT-3711): twice
19
+ * the field. Read off the list's container, not the window or the flow's
20
+ * frame — a host laying out its own two columns puts the list in a ~680px
21
+ * column, under the frame's 900px "wide", and the field still has to stop.
22
+ */
23
+ const FIELD_CAP_FROM_PX = 560;
24
+
18
25
  /**
19
26
  * The CPF at the TOP of Pagamento, above the wallets (FUT-3685, C3, C4, E1,
20
27
  * L7): "CPF", the provider's reason under it ("o PagBank pede para pagar pelo
@@ -40,7 +47,6 @@ export function CpfTop({ field, buyer, onBuyerChange, copy, provider, onComplete
40
47
  const { Button, Checkbox, Input, Text } = useCheckoutComponents();
41
48
  const consent = usePaymentListMode()?.consent;
42
49
  const fieldCopy = useCheckoutCopy().card.fields;
43
- const wide = useCheckoutLayout()?.wide ?? false;
44
50
  // The CPF the store already holds, when the host passed it, shown masked and editable.
45
51
  const value = formatCpf(buyer.taxId ?? "");
46
52
  useOnComplete(value.trim() !== "" && fieldSatisfied(field, value), onComplete);
@@ -50,7 +56,7 @@ export function CpfTop({ field, buyer, onBuyerChange, copy, provider, onComplete
50
56
  }, [focusOnMount]);
51
57
  const complaint = complaintOf(field, value, copy, (typed) => validateCpf(typed, fieldCopy));
52
58
  return (
53
- <Box ref={scope} data-testid="list-cpf" sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
59
+ <Box ref={scope} data-testid="list-cpf" sx={{ display: "flex", flexDirection: "column", gap: 1.5, containerType: "inline-size" }}>
54
60
  {/* A native <label> around the words and the field names the input with both. */}
55
61
  <Box component="label" sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
56
62
  <Box component="span" sx={{ display: "flex", flexDirection: "column" }}>
@@ -61,7 +67,7 @@ export function CpfTop({ field, buyer, onBuyerChange, copy, provider, onComplete
61
67
  {copy.cpfHint(provider)}
62
68
  </Text>
63
69
  </Box>
64
- <Box sx={{ width: "100%", maxWidth: (t) => (wide ? t.spacing(FIELD_WIDE_MAX) : "none") }}>
70
+ <Box data-testid="list-cpf-field" sx={(t) => ({ width: "100%", [`@container (min-width: ${FIELD_CAP_FROM_PX}px)`]: { maxWidth: t.spacing(FIELD_WIDE_MAX) } })}>
65
71
  <Input
66
72
  type="text"
67
73
  inputMode="numeric"
@@ -79,19 +85,25 @@ export function CpfTop({ field, buyer, onBuyerChange, copy, provider, onComplete
79
85
  </Box>
80
86
  </Box>
81
87
  {onKeepSaved && copy.keepSavedCpf ? (
82
- <Box sx={{ alignSelf: "flex-start", ml: -1 }}>
83
- <Button variant="text" color="primary" size="sm" onClick={onKeepSaved} dataTestId="list-cpf-keep">
88
+ // Flush with the field's left edge, in the neutral ink of a link that keeps
89
+ // what is on file — the brand's primary read as an error on a red-brown theme.
90
+ <Box sx={{ alignSelf: "flex-start", "& button": { textDecoration: "underline", px: 0, py: 0, minWidth: 0, minHeight: (t) => t.spacing(3) } }}>
91
+ <Button variant="text" color="neutral" size="sm" onClick={onKeepSaved} dataTestId="list-cpf-keep">
84
92
  {copy.keepSavedCpf}
85
93
  </Button>
86
94
  </Box>
87
95
  ) : null}
88
96
  {consent ? (
89
- <Checkbox
90
- label={consent.label}
91
- checked={consent.checked}
92
- onChange={(_event, checked) => consent.onChange(checked)}
93
- data-testid="list-save-profile"
94
- />
97
+ // Flush with the field: the box's own 9px ring and the label's negative inset
98
+ // (MUI's, under every kit built on it) pushed the words right until they wrapped on a phone.
99
+ <Box data-testid="list-save-profile-row" sx={{ "& .MuiFormControlLabel-root": { ml: 0, mr: 0 }, "& .MuiFormControlLabel-root .MuiCheckbox-root": { p: 0, mr: 1 } }}>
100
+ <Checkbox
101
+ label={consent.label}
102
+ checked={consent.checked}
103
+ onChange={(_event, checked) => consent.onChange(checked)}
104
+ data-testid="list-save-profile"
105
+ />
106
+ </Box>
95
107
  ) : null}
96
108
  </Box>
97
109
  );
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A balance the host holds paying part of the cart, as the "Resumo do pedido"
3
+ * column draws it (FUT-3711). Kept apart from `./types` only for that file's
4
+ * length; both are part of `CheckoutPaymentList` / `PaymentListCopy`.
5
+ */
6
+ export interface PaymentListCredit {
7
+ /**
8
+ * The balance that pays part of the cart, as the host words and formats it
9
+ * — `{ label: "Crédito da loja", amountLabel: "− R$ 5,00" }`. With
10
+ * `amountDueLabel`, the computer's "Resumo do pedido" column draws it under
11
+ * "Total", then "A pagar" (`copy.amountDue`) and the amount due, so Total −
12
+ * credit reads as what the button charges — while an ONLINE option is
13
+ * chosen. A door option or no choice charges the cart's total, so "Total"
14
+ * stands alone. Absent ⇒ "Total" alone.
15
+ */
16
+ credit?: { label: string; amountLabel: string };
17
+ }
18
+
19
+ /** The words of the column's credit rows (FUT-3711). */
20
+ export interface PaymentListCreditCopy {
21
+ /**
22
+ * "A pagar" — the row under "Total" in the computer's "Resumo do pedido"
23
+ * column, after the host's `credit` row, naming `amountDueLabel`. Absent ⇒
24
+ * neither row is drawn.
25
+ */
26
+ amountDue?: string;
27
+ }
@@ -64,6 +64,7 @@ export const EN_US_PAYMENT_LIST_COPY: PaymentListCopy = {
64
64
  payerChange: "Change",
65
65
  charging: (total, card) => `Paying ${total} with ${card ?? "the card entered"}…`,
66
66
  chargingCovered: "Confirming the order, paid with your balance…",
67
+ amountDue: "Amount due",
67
68
  cancel: "Cancel",
68
69
  pixFace: {
69
70
  title: (total) => `Pay ${total} with Pix`,
@@ -0,0 +1,118 @@
1
+ import { Component, Suspense, lazy, type ComponentType, type JSX, type LazyExoticComponent, type ReactNode } from "react";
2
+
3
+ import { useCheckoutComponents, type CheckoutLazy } from "../ui";
4
+
5
+ /** What a part is rendered with to load it and draw nothing (`Preload`). */
6
+ interface Probe {
7
+ readonly probe: true;
8
+ }
9
+
10
+ /** How long a part that failed to load waits before its one retry. */
11
+ export const PART_RETRY_MS = 1000;
12
+
13
+ /**
14
+ * Catches a part whose chunk failed to load (FUT-3711): draws `fallback` in
15
+ * its place — the rest of the list stays — and, with `retry` (the part's
16
+ * `useForget()`), forgets the failed load and tries ONCE more after
17
+ * {@link PART_RETRY_MS}, importing again rather than replaying the error. The
18
+ * retry suspends to a boundary of its own, never to one holding drawn rows.
19
+ */
20
+ export class PartBoundary extends Component<{ fallback: ReactNode; retry?: () => void; children: ReactNode }, { failed: boolean; tries: number }> {
21
+ state = { failed: false, tries: 0 };
22
+ private timer: ReturnType<typeof setTimeout> | null = null;
23
+
24
+ static getDerivedStateFromError(): { failed: boolean } {
25
+ return { failed: true };
26
+ }
27
+
28
+ componentDidCatch(): void {
29
+ const { retry } = this.props;
30
+ if (!retry || this.state.tries > 0) return;
31
+ this.timer = setTimeout(() => {
32
+ retry();
33
+ this.setState({ failed: false, tries: 1 });
34
+ }, PART_RETRY_MS);
35
+ }
36
+
37
+ componentWillUnmount(): void {
38
+ if (this.timer) clearTimeout(this.timer);
39
+ }
40
+
41
+ render(): ReactNode {
42
+ const { fallback, children } = this.props;
43
+ if (this.state.failed) return fallback;
44
+ // A retry suspends here, on its own: the boundary above may hold what the
45
+ // first failure left on screen (the rows under the top), and hiding it
46
+ // while the part imports again would blink it out under a finger.
47
+ return this.state.tries > 0 ? <Suspense fallback={fallback}>{children}</Suspense> : children;
48
+ }
49
+ }
50
+
51
+ /** The part as loaded, answering a probe with nothing. */
52
+ function probed<P extends object>(Part: ComponentType<P>): ComponentType<P | Probe> {
53
+ return function ProbedPart(props: P | Probe): JSX.Element | null {
54
+ return "probe" in props ? null : <Part {...(props as P)} />;
55
+ };
56
+ }
57
+
58
+ /** A part of the list loaded as a chunk of its own — see {@link deferredPart}. */
59
+ interface DeferredPart<P extends object> {
60
+ /** The part, built through the host's loader; it suspends until its chunk has landed. */
61
+ usePart: () => LazyExoticComponent<ComponentType<P | Probe>>;
62
+ /** Drops this host loader's build, so the next render imports again — a `PartBoundary`'s `retry`. */
63
+ useForget: () => () => void;
64
+ /**
65
+ * Starts the part's load through the same loader, drawing nothing: render it
66
+ * as soon as the list knows it MAY draw the part, so the part is in hand
67
+ * when it does — and never suspends where it would push the rows down.
68
+ */
69
+ Preload: () => JSX.Element;
70
+ }
71
+
72
+ /**
73
+ * A part of the list loaded as a chunk of its own (FUT-3711), through the
74
+ * host's `lazy` slot — its stale-chunk-safe loader — or React's `lazy` when the
75
+ * host fills none.
76
+ *
77
+ * Each loader builds the deferred component ONCE and keeps it, keyed by the
78
+ * loader itself: a component made on every render would be a new type every
79
+ * render, remounting the part and suspending it again each time. A load that
80
+ * fails is dropped from that cache — by the import's own rejection, and by the
81
+ * part's `PartBoundary` retry for a failure in the host's loader around it —
82
+ * so the retry imports again instead of rethrowing a rejection kept forever.
83
+ */
84
+ export function deferredPart<P extends object>(load: () => Promise<ComponentType<P>>): DeferredPart<P> {
85
+ const made = new WeakMap<CheckoutLazy, LazyExoticComponent<ComponentType<P | Probe>>>();
86
+ function build(loader: CheckoutLazy): LazyExoticComponent<ComponentType<P | Probe>> {
87
+ const part = loader(() =>
88
+ load().then(
89
+ (Part) => ({ default: probed(Part) }),
90
+ (error: unknown) => {
91
+ made.delete(loader);
92
+ throw error;
93
+ },
94
+ ),
95
+ );
96
+ made.set(loader, part);
97
+ return part;
98
+ }
99
+ function usePart(): LazyExoticComponent<ComponentType<P | Probe>> {
100
+ const loader = useCheckoutComponents().lazy ?? lazy;
101
+ return made.get(loader) ?? build(loader);
102
+ }
103
+ function useForget(): () => void {
104
+ const loader = useCheckoutComponents().lazy ?? lazy;
105
+ return () => made.delete(loader);
106
+ }
107
+ function Preload(): JSX.Element {
108
+ const Part = usePart();
109
+ return (
110
+ <PartBoundary fallback={null}>
111
+ <Suspense fallback={null}>
112
+ <Part probe />
113
+ </Suspense>
114
+ </PartBoundary>
115
+ );
116
+ }
117
+ return { usePart, useForget, Preload };
118
+ }
@@ -12,6 +12,12 @@ import { CHARGE_WINDOW_MS } from "./use-list-charge";
12
12
  const ACTION_HEIGHT = 6.5;
13
13
  const SECONDARY_HEIGHT = 5.5;
14
14
 
15
+ /** A block the host handed that draws nothing takes no row, and so no gap (FUT-3711). */
16
+ const NO_EMPTY_ROW = { "&:empty": { display: "none" } } as const;
17
+
18
+ /** Read by a screen reader, drawn nowhere. */
19
+ const VISUALLY_HIDDEN = { position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" } as const;
20
+
15
21
  /** Focus the first button under `scope` — the slot owns the element, so it is found, not referenced. */
16
22
  function focusButtonIn(scope: HTMLElement | null): void {
17
23
  scope?.querySelector<HTMLButtonElement>("button")?.focus();
@@ -46,19 +52,19 @@ export function ListBar({ summary, extras, payer, reason, live, children }: {
46
52
  const column = useSummaryColumnTarget();
47
53
  const content = (
48
54
  <Box data-testid={column ? "list-pay-bar" : undefined} sx={{ display: "flex", flexDirection: "column", gap: 1, width: "100%", minWidth: 0 }}>
49
- {summary ? <Box data-testid="list-bar-summary">{summary}</Box> : null}
50
- {extras ? <Box data-testid="list-bar-extras">{extras}</Box> : null}
55
+ {/* The context line in the secondary ink in the column, as in the bar — the column inherits whatever the host's element is inked in. */}
56
+ {summary ? <Box data-testid="list-bar-summary" sx={{ ...NO_EMPTY_ROW, ...(column ? { color: "text.secondary" } : {}) }}>{summary}</Box> : null}
57
+ {extras ? <Box data-testid="list-bar-extras" sx={NO_EMPTY_ROW}>{extras}</Box> : null}
51
58
  {payer}
52
59
  {children}
53
- <Box role="status" aria-live="polite" data-testid="list-bar-live">
60
+ {/* Out of the flow while it has nothing to show: an empty row still took a gap under the button. */}
61
+ <Box role="status" aria-live="polite" data-testid="list-bar-live" sx={reason ? undefined : VISUALLY_HIDDEN}>
54
62
  {reason ? (
55
63
  <Text variant="body" size="sm" color="danger" as="p" style={{ margin: 0 }} data-testid="list-blocked-reason">
56
64
  {reason}
57
65
  </Text>
58
66
  ) : (
59
- <Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
60
- {live ?? ""}
61
- </Box>
67
+ <Box component="span">{live ?? ""}</Box>
62
68
  )}
63
69
  </Box>
64
70
  </Box>
@@ -16,7 +16,7 @@ import { OtherBankLink } from "./list-pix";
16
16
  import { ChoiceList } from "./choice-list";
17
17
  import { CardRun, WalletRun, type ListChargeFailure, type WalletSheet } from "./charge-runners";
18
18
  import { ActionButton, ChargeBar, ListBar } from "./list-bar";
19
- import { ListTop, gateFields, walletsHeld } from "./list-top";
19
+ import { ListTop, TopAndRows, gateFields, walletsHeld } from "./list-top";
20
20
  import { PayerLine } from "./payer-line";
21
21
  import type { ListCapability } from "./use-capability";
22
22
  import type { ListCharge } from "./use-list-charge";
@@ -375,11 +375,14 @@ export function ListFace(props: ListFaceProps): JSX.Element {
375
375
  const { returnFocus, onCancel } = useCancelWithFocus(charge);
376
376
  const shown = charge.phase !== "waiting";
377
377
  const facts = barFacts(props, door, reason);
378
- const top = <ListTop props={props} held={wallets.held} failure={wallets.failure} onGoogle={wallets.onGoogle} onApple={wallets.onApple} />;
378
+ const rowsShown = useRef(false);
379
+ const top = <ListTop props={props} held={wallets.held} failure={wallets.failure} onGoogle={wallets.onGoogle} onApple={wallets.onApple} rowsShown={rowsShown} />;
379
380
  return (
380
381
  <Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
381
382
  {shown ? (
382
- <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} covered={coveredByHost(list)} top={top} />
383
+ <TopAndRows rowsShown={rowsShown}>
384
+ <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} covered={coveredByHost(list)} top={top} />
385
+ </TopAndRows>
383
386
  ) : null}
384
387
  <Runners props={props} sheet={wallets.sheet.current} onFailed={onFailed} onWalletFailed={wallets.onFailed} />
385
388
  <RaiseError c={c} onRetry={() => act()} />
@@ -0,0 +1,40 @@
1
+ import { Box } from "@mui/material";
2
+ import { useRef, type JSX } from "react";
3
+
4
+ import { CpfTop } from "./cpf-top";
5
+ import type { ListTopInput } from "./list-top";
6
+ import { cpfProvider } from "./top-cpf";
7
+ import { WalletBlock } from "./wallet-block";
8
+
9
+ /** Move the focus to "Número do cartão" — the next thing to type once the CPF is complete. */
10
+ function focusCardNumber(scope: HTMLElement | null): void {
11
+ scope?.closest('[data-testid="payment-list"]')?.querySelector<HTMLInputElement>('input[data-testid="card-number"]')?.focus();
12
+ }
13
+
14
+ /**
15
+ * What the top of Pagamento draws (FUT-3685): the CPF field, then the
16
+ * "PAGAMENTO RÁPIDO" block — loaded only when `ListTop` decides there is one
17
+ * to draw (FUT-3711).
18
+ */
19
+ export function ListTopBody({ props, held, failure, onGoogle, onApple }: ListTopInput): JSX.Element {
20
+ const scope = useRef<HTMLDivElement | null>(null);
21
+ const { list, state, cap, config, c } = props;
22
+ const typingCard = state.choice?.kind === "online" && state.choice.method === "CARD";
23
+ return (
24
+ <Box ref={scope} data-testid="list-top" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
25
+ {state.top ? (
26
+ <CpfTop
27
+ field={state.top}
28
+ buyer={c.buyer}
29
+ onBuyerChange={c.setBuyer}
30
+ copy={list.copy.buyer}
31
+ provider={cpfProvider(config?.chain, state.methods)}
32
+ onComplete={typingCard ? () => focusCardNumber(scope.current) : undefined}
33
+ focusOnMount={props.revealed}
34
+ onKeepSaved={props.revealed ? props.onKeepSavedCpf : undefined}
35
+ />
36
+ ) : null}
37
+ <WalletBlock cap={cap} copy={list.copy} held={held} failure={failure} onGoogle={onGoogle} onApple={onApple} />
38
+ </Box>
39
+ );
40
+ }
@@ -1,15 +1,14 @@
1
1
  import { Box } from "@mui/material";
2
- import { useRef, type JSX } from "react";
2
+ import { Suspense, useEffect, useState, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import type { BuyerInfo, CheckoutCustomerField, CheckoutProviderConfig, PaymentMethod } from "../types";
5
5
 
6
6
  import type { ListChargeFailure } from "./charge-runners";
7
- import { CpfTop } from "./cpf-top";
8
- import { cpfHolds, cpfProvider } from "./top-cpf";
7
+ import { PartBoundary, deferredPart } from "./lazy-body";
8
+ import { cpfHolds } from "./top-cpf";
9
9
  import type { CheckoutPaymentList } from "./types";
10
10
  import type { ListCapability } from "./use-capability";
11
11
  import type { ListState } from "./use-list-state";
12
- import { WalletBlock } from "./wallet-block";
13
12
 
14
13
  /**
15
14
  * The fields that hold the bar's button for the chosen online option, in the
@@ -46,13 +45,8 @@ export function walletsHeld(input: {
46
45
  return priced ? null : (list.copy.reasons.repricing ?? list.copy.reasons.choose);
47
46
  }
48
47
 
49
- /** Move the focus to "Número do cartão" — the next thing to type once the CPF is complete. */
50
- function focusCardNumber(scope: HTMLElement | null): void {
51
- scope?.closest('[data-testid="payment-list"]')?.querySelector<HTMLInputElement>('input[data-testid="card-number"]')?.focus();
52
- }
53
-
54
48
  /** What the top of the list reads, gathered by the face. */
55
- interface ListTopFacts {
49
+ export interface ListTopFacts {
56
50
  list: CheckoutPaymentList;
57
51
  state: ListState;
58
52
  cap: ListCapability;
@@ -63,37 +57,97 @@ interface ListTopFacts {
63
57
  onKeepSavedCpf: () => void;
64
58
  }
65
59
 
66
- /**
67
- * The top of Pagamento (FUT-3685): the CPF, asked once (E1, L7), then the
68
- * "PAGAMENTO RÁPIDO" block (C5, C6) — both above the rows.
69
- */
70
- export function ListTop({ props, held, failure, onGoogle, onApple }: {
60
+ /** What the top of the list is handed by the face. */
61
+ export interface ListTopInput {
71
62
  props: ListTopFacts;
72
63
  held: string | null;
73
64
  /** The last wallet charge's refusal, said under the buttons. */
74
65
  failure: ListChargeFailure | null;
75
66
  onGoogle: () => void;
76
67
  onApple: () => void;
77
- }): JSX.Element | null {
78
- const scope = useRef<HTMLDivElement | null>(null);
79
- const { list, state, cap, config, c } = props;
68
+ /** Whether the rows under the top have been drawn yet — set by the face once they are. */
69
+ rowsShown?: { readonly current: boolean };
70
+ }
71
+
72
+ /** The top's chunk (FUT-3711): `ListTop` draws it; the step preloads it (`TopPart.Preload`). */
73
+ export const TopPart = deferredPart<ListTopInput>(async () => (await import("./list-top-body")).ListTopBody);
74
+
75
+ /**
76
+ * The top of Pagamento (FUT-3685): the CPF, asked once (E1, L7), then the
77
+ * "PAGAMENTO RÁPIDO" block (C5, C6) — both above the rows.
78
+ *
79
+ * Only the decision to draw it is here; what it draws is a chunk of its own
80
+ * (FUT-3711), so a checkout with neither a CPF to ask nor a wallet ready never
81
+ * parses it. The step starts that chunk as soon as it mounts
82
+ * (`TopPart.Preload`). A top drawn with the list's first rows SUSPENDS to the
83
+ * boundary the face puts around the top AND the rows (`ChoiceList`): the two
84
+ * land together, so no row is pushed down under a finger. A top that appears
85
+ * once the rows are drawn suspends on its own and never hides them. A chunk that
86
+ * fails is caught here — the rows stay, nothing is drawn in the top's place,
87
+ * and it is tried once more. What the CPF holds — the pay button, the wallets
88
+ * — is decided in this file (`gateFields`, `walletsHeld`), so the hold is in
89
+ * force whether or not the field is on screen.
90
+ */
91
+ export function ListTop(input: ListTopInput): JSX.Element | null {
92
+ const { state, cap } = input.props;
80
93
  if (!state.top && cap.wallets.length === 0) return null;
81
- const typingCard = state.choice?.kind === "online" && state.choice.method === "CARD";
94
+ return <TopWithRetry {...input} />;
95
+ }
96
+
97
+ /**
98
+ * The top behind its own boundary: a failed chunk draws nothing there, then is
99
+ * tried once more. A top that first appears AFTER the rows are on screen — a
100
+ * wallet that turned ready, a CPF opened by "Alterar" — also suspends on its
101
+ * own (nothing drawn while it loads), so it can never hide rows already drawn.
102
+ * Decided once, when the top mounts: a boundary that came and went would
103
+ * remount what is under it.
104
+ */
105
+ function TopWithRetry(input: ListTopInput): JSX.Element {
106
+ const forget = TopPart.useForget();
107
+ const [late] = useState(() => input.rowsShown?.current === true);
108
+ const top = <DeferredTop {...input} />;
109
+ return (
110
+ <PartBoundary fallback={null} retry={forget}>
111
+ {late ? <Suspense fallback={<TopLoading />}>{top}</Suspense> : top}
112
+ </PartBoundary>
113
+ );
114
+ }
115
+
116
+ /** The top's chunk, asked of the host's loader only once there is a top to draw. */
117
+ function DeferredTop(input: ListTopInput): JSX.Element {
118
+ const Body = TopPart.usePart();
119
+ return <Body {...input} />;
120
+ }
121
+
122
+ /**
123
+ * What the top and the rows show while the top's chunk is on its way: nothing
124
+ * — an undrawn `list-top-loading` marker a test can wait on (`display: none`
125
+ * takes no row and no gap). The bar under them is not inside the boundary, so
126
+ * its button keeps its place and its hold.
127
+ */
128
+ function TopLoading(): JSX.Element {
129
+ return <Box data-testid="list-top-loading" sx={{ display: "none" }} />;
130
+ }
131
+
132
+ /** Marks the rows as drawn once they commit — a top appearing after that suspends on its own. */
133
+ function RowsShown({ into }: { into: { current: boolean } }): null {
134
+ useEffect(() => {
135
+ into.current = true;
136
+ }, [into]);
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * The top and the rows under ONE boundary (FUT-3711): a top drawn with the
142
+ * first rows lands with them, so its chunk never pushes a row down. Once the
143
+ * rows have committed, `rowsShown` says so, and a top that appears later
144
+ * suspends on its own instead (`TopWithRetry`).
145
+ */
146
+ export function TopAndRows({ rowsShown, children }: { rowsShown: { current: boolean }; children: ReactNode }): JSX.Element {
82
147
  return (
83
- <Box ref={scope} data-testid="list-top" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
84
- {state.top ? (
85
- <CpfTop
86
- field={state.top}
87
- buyer={c.buyer}
88
- onBuyerChange={c.setBuyer}
89
- copy={list.copy.buyer}
90
- provider={cpfProvider(config?.chain, state.methods)}
91
- onComplete={typingCard ? () => focusCardNumber(scope.current) : undefined}
92
- focusOnMount={props.revealed}
93
- onKeepSaved={props.revealed ? props.onKeepSavedCpf : undefined}
94
- />
95
- ) : null}
96
- <WalletBlock cap={cap} copy={list.copy} held={held} failure={failure} onGoogle={onGoogle} onApple={onApple} />
97
- </Box>
148
+ <Suspense fallback={<TopLoading />}>
149
+ {children}
150
+ <RowsShown into={rowsShown} />
151
+ </Suspense>
98
152
  );
99
153
  }
@@ -35,6 +35,9 @@ const FIELDS_ON_SURFACE = {
35
35
  },
36
36
  } as const;
37
37
 
38
+ /** The small radio (20px) centred on the title's 24px line. */
39
+ const RADIO_ON_TITLE = 0.25;
40
+
38
41
  /** The row's label and its muted hint, beside the radio. */
39
42
  function RowWords({ label, hint }: { label: string; hint?: string }): JSX.Element {
40
43
  const { Text } = useCheckoutComponents();
@@ -114,8 +117,9 @@ export function OptionRow({
114
117
  // The prototype's `.opt` padding, 12px above and below the label and its
115
118
  // hint (FUT-3685 review): a row with a hint is ~62px, and the hint never
116
119
  // touches — or is clipped by — the row's border. 48px is the floor, for
117
- // a row with no hint.
118
- sx={{ display: "flex", alignItems: "center", gap: 1.5, minHeight: (t) => t.spacing(6), py: 1.5, cursor: disabled ? "default" : "pointer" }}
120
+ // a row with no hint. The radio sits on the TITLE's line, not centred on
121
+ // title and hint together — a wrapped hint no longer drags it down (FUT-3711).
122
+ sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minHeight: (t) => t.spacing(6), py: 1.5, boxSizing: "border-box", cursor: disabled ? "default" : "pointer" }}
119
123
  >
120
124
  <Radio
121
125
  name={name}
@@ -124,7 +128,7 @@ export function OptionRow({
124
128
  disabled={disabled}
125
129
  onChange={onChoose}
126
130
  size="small"
127
- sx={{ p: 0, flex: "none" }}
131
+ sx={{ p: 0, flex: "none", mt: RADIO_ON_TITLE }}
128
132
  inputProps={{ "data-testid": `${testId}-radio` } as Record<string, string>}
129
133
  />
130
134
  <RowWords label={label} hint={hint} />
@@ -26,7 +26,9 @@ export function PayerLine({ copy, name, onChange }: {
26
26
  </Text>
27
27
  <Box
28
28
  sx={{
29
- "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5), minWidth: 0, px: 0.5, py: 0 },
29
+ // The 40px target reaches into the gaps above and below rather than making the
30
+ // line 40px tall: a row of text in the bar, not a row of button (FUT-3711).
31
+ "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5), height: (t) => t.spacing(5), minWidth: 0, px: 0.5, py: 0, my: -1 },
30
32
  "& button:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
31
33
  }}
32
34
  >
@@ -15,11 +15,12 @@ import type { useCheckoutController } from "../use-checkout-controller";
15
15
  import { PaymentListProvider } from "./context";
16
16
  import { ListBar, WaitingLine } from "./list-bar";
17
17
  import { ListFace } from "./list-face";
18
+ import { TopPart } from "./list-top";
18
19
  import { ListLayout } from "./summary-column";
19
20
  import type { ListCapability } from "./use-capability";
20
21
  import type { ListCharge } from "./use-list-charge";
21
22
  import type { ListState } from "./use-list-state";
22
- import type { CheckoutPaymentList } from "./types";
23
+ import type { CheckoutListChoice, CheckoutPaymentList } from "./types";
23
24
  import { useListCapability } from "./use-capability";
24
25
  import { useListCharge } from "./use-list-charge";
25
26
  import { useListState } from "./use-list-state";
@@ -155,6 +156,32 @@ function useDebitReady(list: CheckoutPaymentList): boolean {
155
156
  return Boolean(list.billingAddress) && list.copy.debit !== undefined && client.threeDSecureSession !== undefined;
156
157
  }
157
158
 
159
+ /**
160
+ * Starts the top's chunk the moment the list knows it MAY draw a top
161
+ * (FUT-3711): a CPF to ask, a wallet the chain head DECLARES (readiness —
162
+ * pay.js, the device, the host's price — arrives only after the first
163
+ * render), or a CPF on file that "Alterar" can open. So the top is in hand
164
+ * when it is drawn.
165
+ */
166
+ function PreloadTop({ state, cap, config, taxIdOnFile }: { state: ListState; cap: ListCapability; config: CheckoutProviderConfig | null; taxIdOnFile: boolean }): JSX.Element | null {
167
+ const declared = (config?.chain?.[0]?.wallets?.length ?? 0) > 0;
168
+ if (!state.top && cap.wallets.length === 0 && !declared && !taxIdOnFile) return null;
169
+ return <TopPart.Preload />;
170
+ }
171
+
172
+ /**
173
+ * The column's credit and "A pagar" rows (FUT-3711) — only with all three of
174
+ * the host's credit, amount due and words, and only while an ONLINE option is
175
+ * chosen: that is the only time the button charges the amount due. A door
176
+ * option or no choice charges the cart's total, so "Total" stands alone — a
177
+ * balance the host holds pays no door tender.
178
+ */
179
+ function creditRows(list: CheckoutPaymentList, choice: CheckoutListChoice | null): { label: string; amountLabel: string; dueTitle: string; dueLabel: string } | undefined {
180
+ if (choice?.kind !== "online") return undefined;
181
+ if (!list.credit || !list.amountDueLabel || !list.copy.amountDue) return undefined;
182
+ return { ...list.credit, dueTitle: list.copy.amountDue, dueLabel: list.amountDueLabel };
183
+ }
184
+
158
185
  /**
159
186
  * The Pagamento step as ONE list (FUT-3591, FUT-3592) — see `./types.ts`.
160
187
  *
@@ -201,6 +228,7 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
201
228
  lines={props.totalOverride ? undefined : props.cart.lines}
202
229
  moneyLines={props.cart.discountLines}
203
230
  totalLabel={displayTotals(props.totalOverride, props.cart).label}
231
+ credit={creditRows(list, state.choice)}
204
232
  >
205
233
  <ListFaceFor props={props} state={state} charge={charge} cap={cap} onHandOff={startHandOff} reveal={reveal} />
206
234
  </ListLayout>
@@ -208,6 +236,7 @@ export function PaymentListStep(props: PaymentListStepProps): JSX.Element {
208
236
  return (
209
237
  <PaymentListProvider list={list} onLeavePix={c.dropOrder} consent={consent}>
210
238
  <PixStageProvider stage={pixStage}>
239
+ <PreloadTop state={state} cap={cap} config={config} taxIdOnFile={props.taxIdOnFile} />
211
240
  <CartChangedBoundary
212
241
  order={c.order}
213
242
  creating={c.creating}
@@ -80,6 +80,7 @@ export const PT_BR_PAYMENT_LIST_COPY: PaymentListCopy = {
80
80
  payerChange: "Alterar",
81
81
  charging: (total, card) => `Pagando ${total} no ${card ?? "cartão informado"}…`,
82
82
  chargingCovered: "Confirmando o pedido, pago com o seu saldo…",
83
+ amountDue: "A pagar",
83
84
  cancel: "Cancelar",
84
85
  pixFace: {
85
86
  title: (total) => `Pague ${total} com Pix`,
@@ -0,0 +1,61 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, ReactNode } from "react";
3
+
4
+ import { SummaryLines, SummaryTotal, type CheckoutSummaryLine } from "../payment-summary";
5
+ import { useCheckoutComponents } from "../ui";
6
+
7
+ /** What the loaded half of "Resumo do pedido" is handed by the layout. */
8
+ export interface ColumnBodyProps {
9
+ lines?: CheckoutSummaryLine[];
10
+ /** The host's fee and discount rows (`CheckoutCartView.discountLines`). */
11
+ moneyLines?: ReactNode;
12
+ totalLabel: string;
13
+ /**
14
+ * A balance paying part of the cart, and what is left to pay (FUT-3711):
15
+ * drawn under "Total" so Total − credit reads as what the button charges.
16
+ */
17
+ credit?: { label: string; amountLabel: string; dueTitle: string; dueLabel: string };
18
+ }
19
+
20
+ /** One label/amount row under "Total". */
21
+ function AmountRow({ label, amount, strong = false, testId }: { label: string; amount: string; strong?: boolean; testId: string }): JSX.Element {
22
+ const { Text } = useCheckoutComponents();
23
+ const weight = strong ? "semibold" : undefined;
24
+ return (
25
+ <Box data-testid={testId} sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
26
+ <Text variant="body" size={strong ? "md" : "sm"} weight={weight} color={strong ? undefined : "secondary"} as="span">
27
+ {label}
28
+ </Text>
29
+ <Box component="span" sx={{ flex: "none", whiteSpace: "nowrap" }}>
30
+ <Text variant="body" size={strong ? "md" : "sm"} weight={weight} color={strong ? undefined : "secondary"} as="span">
31
+ {amount}
32
+ </Text>
33
+ </Box>
34
+ </Box>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * What "Resumo do pedido" loads (T20, FUT-3711): the cart's lines and the
40
+ * host's money rows (fees, discounts) as the host words them, a hairline,
41
+ * "Total" and the amount, and the credit and "A pagar" rows. The frame, the
42
+ * heading and the pay-button slot under them are the layout's own — eager —
43
+ * so the bar's content never changes element when this lands.
44
+ */
45
+ export function SummaryBody({ lines, moneyLines, totalLabel, credit }: ColumnBodyProps): JSX.Element {
46
+ const shown = lines ?? [];
47
+ return (
48
+ <Box data-testid="list-summary-body" sx={{ display: "flex", flexDirection: "column", gap: 1.75 }}>
49
+ {shown.length > 0 ? <SummaryLines lines={shown} /> : null}
50
+ {moneyLines ? <Box data-testid="list-summary-money">{moneyLines}</Box> : null}
51
+ <Box sx={{ height: "1px", bgcolor: "divider" }} />
52
+ <SummaryTotal totalLabel={totalLabel} />
53
+ {credit ? (
54
+ <Box data-testid="list-summary-credit" sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
55
+ <AmountRow label={credit.label} amount={credit.amountLabel} testId="list-summary-credit-row" />
56
+ <AmountRow label={credit.dueTitle} amount={credit.dueLabel} strong testId="list-summary-due-row" />
57
+ </Box>
58
+ ) : null}
59
+ </Box>
60
+ );
61
+ }
@@ -1,12 +1,15 @@
1
1
  import { Box } from "@mui/material";
2
- import { createContext, useContext, useMemo, useState, type JSX, type ReactNode } from "react";
2
+ import { Suspense, createContext, useContext, useMemo, useState, type JSX, type ReactNode } from "react";
3
3
  import { createPortal } from "react-dom";
4
4
 
5
5
  import { useCheckoutCopy } from "../copy-context";
6
- import { SummaryLines, SummaryTotal, type CheckoutSummaryLine } from "../payment-summary";
6
+ import type { CheckoutSummaryLine } from "../payment-summary";
7
7
  import { useCheckoutLayout } from "../payment-step-layout";
8
8
  import { useCheckoutComponents } from "../ui";
9
9
 
10
+ import { PartBoundary, deferredPart } from "./lazy-body";
11
+ import type { ColumnBodyProps } from "./summary-column-body";
12
+
10
13
  /**
11
14
  * Where the bar's content goes on a computer (FUT-3685, L4): into the
12
15
  * "Resumo do pedido" column — an element the column draws, which the bar's
@@ -30,36 +33,28 @@ export function useSummaryColumnTarget(): { element: HTMLElement | null } | null
30
33
  const STEPS_MAX = 85;
31
34
  const COLUMN_WIDTH = 45;
32
35
 
33
- /**
34
- * The computer's two columns (L4): the steps on the left, at most ~680px, and
35
- * the "Resumo do pedido" column on the right, ~360px, holding the lines, the
36
- * fees, "Total", the host's context line and the pay button — so no bar sits
37
- * at the bottom of the screen.
38
- */
39
- function TwoColumns({ steps, column }: { steps: ReactNode; column: ReactNode }): JSX.Element {
40
- return (
41
- <Box data-testid="list-columns" sx={{ display: "flex", gap: 4, alignItems: "flex-start", justifyContent: "center", width: "100%" }}>
42
- <Box sx={{ flex: 1, minWidth: 0, maxWidth: (t) => t.spacing(STEPS_MAX) }}>{steps}</Box>
43
- <Box sx={{ flex: "none", width: (t) => t.spacing(COLUMN_WIDTH), position: "sticky", top: (t) => t.spacing(3) }}>{column}</Box>
44
- </Box>
45
- );
36
+ /** The column's loaded half (FUT-3711): lines, money rows, "Total", the credit rows. */
37
+ const ColumnPart = deferredPart<ColumnBodyProps>(async () => (await import("./summary-column-body")).SummaryBody);
38
+
39
+ /** The loaded half, drawn through the host's loader. */
40
+ function DeferredBody(props: ColumnBodyProps): JSX.Element {
41
+ const Body = ColumnPart.usePart();
42
+ return <Body {...props} />;
46
43
  }
47
44
 
48
45
  /**
49
- * "Resumo do pedido" (T20): the cart's lines and the host's money rows (fees,
50
- * discounts) as the host words them, a hairline, "Total" and the amount — and
51
- * under them `slot`, the element the bar's content is portalled into.
46
+ * "Resumo do pedido" (T20). The frame, the heading and the pay-button slot are
47
+ * drawn at once and never change element (FUT-3711): the bar's content is
48
+ * portalled into that ONE slot from the first render, so the body landing —
49
+ * lines, money rows, "Total" — moves nothing out from under it (the focus on
50
+ * "Pagar", the host's extras, a 5-second bar counting). Only the body is a
51
+ * chunk of its own, which a phone never draws and so never parses; a body that
52
+ * fails to load leaves the frame and the button, and is tried once more.
52
53
  */
53
- function SummaryColumn({ lines, moneyLines, totalLabel, slot }: {
54
- lines?: CheckoutSummaryLine[];
55
- /** The host's fee and discount rows (`CheckoutCartView.discountLines`). */
56
- moneyLines?: ReactNode;
57
- totalLabel: string;
58
- slot: (element: HTMLElement | null) => void;
59
- }): JSX.Element {
54
+ function SummaryColumn({ slot, ...body }: ColumnBodyProps & { slot: (element: HTMLElement | null) => void }): JSX.Element {
60
55
  const { Text } = useCheckoutComponents();
61
56
  const copy = useCheckoutCopy().screens.layout;
62
- const shown = lines ?? [];
57
+ const forget = ColumnPart.useForget();
63
58
  return (
64
59
  <Box
65
60
  component="aside"
@@ -70,10 +65,11 @@ function SummaryColumn({ lines, moneyLines, totalLabel, slot }: {
70
65
  <Text variant="heading" size="md" weight="bold" as="h2" style={{ margin: 0 }}>
71
66
  {copy.summaryTitle}
72
67
  </Text>
73
- {shown.length > 0 ? <SummaryLines lines={shown} /> : null}
74
- {moneyLines ? <Box data-testid="list-summary-money">{moneyLines}</Box> : null}
75
- <Box sx={{ height: "1px", bgcolor: "divider" }} />
76
- <SummaryTotal totalLabel={totalLabel} />
68
+ <PartBoundary fallback={null} retry={forget}>
69
+ <Suspense fallback={<Box data-testid="list-summary-loading" sx={{ display: "none" }} />}>
70
+ <DeferredBody {...body} />
71
+ </Suspense>
72
+ </PartBoundary>
77
73
  <Box ref={slot} data-testid="list-summary-action" sx={{ display: "flex", flexDirection: "column" }} />
78
74
  </Box>
79
75
  );
@@ -86,35 +82,61 @@ function SummaryColumn({ lines, moneyLines, totalLabel, slot }: {
86
82
  * the "Resumo do pedido" column is portalled into the host's element, beside
87
83
  * the host's own step cards, and the bar's action lands in it. While the
88
84
  * element is `null` (not mounted yet) the bar draws nothing rather than flash;
89
- * - a computer, the host silent: the step and the column side by side, here;
85
+ * - a computer, the host silent: the step and the column side by side, here —
86
+ * the steps at most ~680px, the column ~360px and sticky;
90
87
  * - a phone: the step alone, over the host's bar (L2).
91
88
  *
89
+ * The step sits at the SAME place in the tree in all three (FUT-3711): the
90
+ * same two wrappers, only their styles and the column beside them change. A
91
+ * host crossing its two-column threshold (an iPad rotated between 1366 and
92
+ * 1024) or the frame crossing `WIDE_CHECKOUT_MIN` re-lays the step out — it
93
+ * never remounts it, so a typed card and the chosen row survive.
94
+ *
92
95
  * Only the list face is laid out this way: the Pix screen and the hand-off keep
93
96
  * their bar (K9).
94
97
  */
95
- export function ListLayout({ hostTarget, lines, moneyLines, totalLabel, children }: {
98
+ export function ListLayout({ hostTarget, lines, moneyLines, totalLabel, credit, children }: {
96
99
  hostTarget: HTMLElement | null | undefined;
97
100
  lines?: CheckoutSummaryLine[];
98
101
  moneyLines?: ReactNode;
99
102
  totalLabel: string;
103
+ credit?: ColumnBodyProps["credit"];
100
104
  children: ReactNode;
101
105
  }): JSX.Element {
102
106
  const wide = useCheckoutLayout()?.wide ?? false;
103
107
  const [slot, setSlot] = useState<HTMLElement | null>(null);
104
108
  const target = useMemo(() => ({ element: slot }), [slot]);
105
- const column = <SummaryColumn lines={lines} moneyLines={moneyLines} totalLabel={totalLabel} slot={setSlot} />;
106
- if (hostTarget !== undefined) {
107
- return (
108
- <SummaryColumnTarget value={target}>
109
- {children}
110
- {hostTarget ? createPortal(column, hostTarget) : null}
111
- </SummaryColumnTarget>
112
- );
113
- }
114
- if (!wide) return <>{children}</>;
109
+ const hosted = hostTarget !== undefined;
110
+ const sideBySide = !hosted && wide;
111
+ const column = <SummaryColumn lines={lines} moneyLines={moneyLines} totalLabel={totalLabel} credit={credit} slot={setSlot} />;
115
112
  return (
116
- <SummaryColumnTarget value={target}>
117
- <TwoColumns steps={children} column={column} />
113
+ <SummaryColumnTarget value={hosted || sideBySide ? target : null}>
114
+ <Columns sideBySide={sideBySide} column={column}>
115
+ {children}
116
+ </Columns>
117
+ {hostTarget ? createPortal(column, hostTarget) : null}
118
118
  </SummaryColumnTarget>
119
119
  );
120
120
  }
121
+
122
+ /** Not laid out: the wrappers stay in the tree, drawing nothing. */
123
+ const UNLAID = { display: "contents" } as const;
124
+
125
+ /**
126
+ * The step, and beside it on a computer the column: the steps at most ~680px,
127
+ * the column ~360px and sticky. On a phone or beside the host's own columns
128
+ * the same two wrappers are kept, unlaid, so the step never changes place.
129
+ */
130
+ function Columns({ sideBySide, column, children }: { sideBySide: boolean; column: ReactNode; children: ReactNode }): JSX.Element {
131
+ return (
132
+ <Box
133
+ data-testid={sideBySide ? "list-columns" : undefined}
134
+ sx={sideBySide ? { display: "flex", gap: 4, alignItems: "flex-start", justifyContent: "center", width: "100%" } : UNLAID}
135
+ >
136
+ <Box sx={sideBySide ? { flex: 1, minWidth: 0, maxWidth: (t) => t.spacing(STEPS_MAX) } : UNLAID}>{children}</Box>
137
+ {sideBySide ? (
138
+ <Box sx={{ flex: "none", width: (t) => t.spacing(COLUMN_WIDTH), position: "sticky", top: (t) => t.spacing(3) }}>{column}</Box>
139
+ ) : null}
140
+ </Box>
141
+ );
142
+ }
@@ -5,8 +5,7 @@
5
5
  * the only thing that creates the order and the charge.
6
6
  *
7
7
  * Opt-in, through `CheckoutFlowProps.paymentList`. A host that passes nothing
8
- * keeps the method tabs, the order summary and the raise-on-pick it has today
9
- * — this is additive, not a replacement.
8
+ * keeps the method tabs, the order summary and the raise-on-pick it has today — this is additive.
10
9
  *
11
10
  * ## The door options are the HOST's
12
11
  *
@@ -20,6 +19,7 @@
20
19
  */
21
20
  import type { ReactNode } from "react";
22
21
 
22
+ import type { PaymentListCredit, PaymentListCreditCopy } from "./credit-types";
23
23
  import type { CheckoutBillingAddress, PaymentListDebitCopy } from "./debit-copy";
24
24
 
25
25
  export type { CheckoutBillingAddress, PaymentListDebitCopy };
@@ -121,7 +121,7 @@ export interface CheckoutListHold {
121
121
  }
122
122
 
123
123
  /** `CheckoutFlowProps.paymentList` — see the module comment. */
124
- export interface CheckoutPaymentList {
124
+ export interface CheckoutPaymentList extends PaymentListCredit {
125
125
  /** Every sentence the list draws. REQUIRED, with no default (FUT-760). */
126
126
  copy: PaymentListCopy;
127
127
  /**
@@ -282,7 +282,7 @@ export interface PixBankCopy {
282
282
  }
283
283
 
284
284
  /** The list's words — a pack per locale ships at `@12-apps/payments-frontend/locales`. */
285
- export interface PaymentListCopy {
285
+ export interface PaymentListCopy extends PaymentListCreditCopy {
286
286
  /** "Como você quer pagar?" */
287
287
  title: string;
288
288
  /** "Pagar agora, pelo app" — drawn in capitals by CSS. */
@@ -30,6 +30,12 @@ export interface ListCard {
30
30
  /** Everything the one list holds, and nothing it raises. */
31
31
  export interface ListState {
32
32
  rows: OnlineRow[];
33
+ /**
34
+ * The saved cards answered and the one preselection ran (FUT-3849). Until
35
+ * then nothing may be offered to tap: the preselected card opens its chips
36
+ * when it lands and moves every row below it, under the buyer's finger.
37
+ */
38
+ settled: boolean;
33
39
  choice: CheckoutListChoice | null;
34
40
  choose: (next: CheckoutListChoice) => void;
35
41
  card: ListCard;
@@ -71,14 +77,19 @@ function usePreselect(
71
77
  lastMethod: "PIX" | "CARD" | null,
72
78
  apply: (choice: CheckoutListChoice, selection: string | null) => void,
73
79
  touched: { current: boolean },
74
- ): void {
80
+ ): boolean {
75
81
  const done = useRef(false);
82
+ // Set in the same effect as the preselection, so the render that first
83
+ // draws the rows already holds the choice they will keep.
84
+ const [settled, setSettled] = useState(false);
76
85
  useEffect(() => {
77
86
  if (done.current || touched.current || !loaded) return;
78
87
  done.current = true;
79
88
  const pick = preselection(rows, saved, fresh, lastMethod);
80
89
  if (pick) apply(pick.choice, pick.selection);
90
+ setSettled(true);
81
91
  }, [loaded, rows, saved, fresh, lastMethod, apply, touched]);
92
+ return settled;
82
93
  }
83
94
 
84
95
  /**
@@ -147,7 +158,7 @@ export function useListState(input: {
147
158
  },
148
159
  [setSelection],
149
160
  );
150
- usePreselect(loaded, rows, cards, input.fresh, input.lastMethod ?? null, apply, touched);
161
+ const settled = usePreselect(loaded, rows, cards, input.fresh, input.lastMethod ?? null, apply, touched);
151
162
  const choose = useCallback(
152
163
  (next: CheckoutListChoice) => {
153
164
  touched.current = true;
@@ -195,7 +206,7 @@ export function useListState(input: {
195
206
  }),
196
207
  [input.config, methods, input.arrivedWith, input.taxIdOnFile, input.revealed],
197
208
  );
198
- return { rows, choice, choose, card, asked, top, methods };
209
+ return { rows, settled, choice, choose, card, asked, top, methods };
199
210
  }
200
211
 
201
212
  /** The chain methods the list's rows and wallets would raise, each once. */
@@ -15,7 +15,7 @@
15
15
  * mapping. The contract is documented for adopters in
16
16
  * `packages/payments/ADOPTING.md` §3.
17
17
  */
18
- import { createContext, useContext, useMemo, type ChangeEvent, type ComponentType, type CSSProperties, type JSX, type ReactNode } from 'react';
18
+ import { createContext, useContext, useMemo, type ChangeEvent, type ComponentType, type CSSProperties, type JSX, type lazy, type ReactNode } from 'react';
19
19
 
20
20
  import { defaultCheckoutComponents } from './mui-defaults';
21
21
 
@@ -127,6 +127,22 @@ export interface CheckoutActionBarProps {
127
127
  dataTestId?: string;
128
128
  }
129
129
 
130
+ /**
131
+ * How the checkout defers a part of a screen to a chunk of its own
132
+ * (FUT-3711): React's `lazy` signature, `(factory) => Component`. The list
133
+ * loads the CPF/wallet block above its rows and the computer's "Resumo do
134
+ * pedido" column through it, so a checkout that never draws them never parses
135
+ * them.
136
+ *
137
+ * A host whose deploys replace hashed chunks passes its stale-chunk-safe
138
+ * loader here (one that reloads onto the current build when an old tab asks
139
+ * for a chunk that is gone). Pass the SAME function every render: each loader
140
+ * builds its deferred components once and keeps them, so a new function per
141
+ * render re-suspends those parts every time. Unfilled, the default is
142
+ * React's own `lazy`, which has no stale-chunk recovery.
143
+ */
144
+ export type CheckoutLazy = typeof lazy;
145
+
130
146
  /** Every primitive the buyer checkout renders through. */
131
147
  export interface CheckoutComponents {
132
148
  Text: ComponentType<CheckoutTextProps>;
@@ -138,6 +154,8 @@ export interface CheckoutComponents {
138
154
  Stepper: ComponentType<CheckoutStepperProps>;
139
155
  RadioGroup: ComponentType<CheckoutRadioGroupProps>;
140
156
  ActionBar: ComponentType<CheckoutActionBarProps>;
157
+ /** Not a primitive but a loader — see `CheckoutLazy`. Absent ⇒ React's `lazy`. */
158
+ lazy?: CheckoutLazy;
141
159
  }
142
160
 
143
161
  const CheckoutComponentsContext = createContext<CheckoutComponents>(defaultCheckoutComponents);
package/src/index.ts CHANGED
@@ -190,6 +190,7 @@ export {
190
190
  type CheckoutCheckboxProps,
191
191
  type CheckoutComponents,
192
192
  type CheckoutInputProps,
193
+ type CheckoutLazy,
193
194
  type CheckoutLoadingStateProps,
194
195
  type CheckoutRadioGroupProps,
195
196
  type CheckoutRadioOption,
@@ -206,11 +207,7 @@ export {
206
207
  * host owns the cart and therefore has to build it — see `basket.ts` for why
207
208
  * the identity is the lines and never the cart's id.
208
209
  */
209
- export {
210
- basketSignature,
211
- type CheckoutBasketIdentity,
212
- type CheckoutBasketLine,
213
- } from './components/checkout/basket';
210
+ export { basketSignature, type CheckoutBasketIdentity, type CheckoutBasketLine } from './components/checkout/basket';
214
211
  /**
215
212
  * WHY a card was refused, and whether another attempt could work (FUT-1145).
216
213
  * A host wiring the confirmation screen's per-reason copy names these.