@12-apps/payments-frontend 3.36.0 → 3.38.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/components/checkout/card-sources.ts +131 -0
  3. package/src/components/checkout/card-view.tsx +6 -2
  4. package/src/components/checkout/cart-changed.tsx +50 -18
  5. package/src/components/checkout/checkout-actions.ts +18 -0
  6. package/src/components/checkout/checkout-flow.tsx +24 -77
  7. package/src/components/checkout/google-pay-button.tsx +4 -4
  8. package/src/components/checkout/list/buyer-inline.tsx +125 -0
  9. package/src/components/checkout/list/charge-runners.tsx +156 -0
  10. package/src/components/checkout/list/choice-list.tsx +156 -0
  11. package/src/components/checkout/list/choice.ts +193 -0
  12. package/src/components/checkout/list/context.tsx +55 -0
  13. package/src/components/checkout/list/en-US.ts +61 -0
  14. package/src/components/checkout/list/list-bar.tsx +186 -0
  15. package/src/components/checkout/list/list-face.tsx +315 -0
  16. package/src/components/checkout/list/list-pix.tsx +206 -0
  17. package/src/components/checkout/list/online-content.tsx +137 -0
  18. package/src/components/checkout/list/option-row.tsx +123 -0
  19. package/src/components/checkout/list/payment-list-step.tsx +166 -0
  20. package/src/components/checkout/list/pt-BR.ts +77 -0
  21. package/src/components/checkout/list/types.ts +187 -0
  22. package/src/components/checkout/list/use-capability.ts +53 -0
  23. package/src/components/checkout/list/use-list-charge.ts +161 -0
  24. package/src/components/checkout/list/use-list-state.ts +152 -0
  25. package/src/components/checkout/payment-phase.tsx +119 -0
  26. package/src/components/checkout/pix-after-copy.tsx +10 -3
  27. package/src/components/checkout/pix-pane.tsx +33 -22
  28. package/src/components/checkout/pix-parts.tsx +50 -1
  29. package/src/components/checkout/pix-view.tsx +53 -20
  30. package/src/components/checkout/screens-copy.ts +6 -0
  31. package/src/components/checkout/screens-en-US.ts +1 -0
  32. package/src/components/checkout/screens-pt-BR.ts +1 -0
  33. package/src/components/checkout/use-card-checkout.ts +11 -78
  34. package/src/components/checkout/use-checkout-controller.ts +3 -1
  35. package/src/flows/create-payment-flows.tsx +7 -0
  36. package/src/flows/types.ts +7 -0
  37. package/src/index.ts +4 -5
  38. package/src/locales.ts +13 -0
@@ -0,0 +1,186 @@
1
+ import { Box } from "@mui/material";
2
+ import { keyframes } from "@mui/material/styles";
3
+ import { useEffect, useRef, type JSX, type ReactNode } from "react";
4
+
5
+ import { useCheckoutCopy } from "../copy-context";
6
+ import type { GooglePaymentsClient } from "../google-pay-button";
7
+ import { useCheckoutComponents } from "../ui";
8
+
9
+ import { CHARGE_WINDOW_MS } from "./use-list-charge";
10
+
11
+ /** The prototype's 52px action and 44px secondary, on the theme's spacing scale. */
12
+ const ACTION_HEIGHT = 6.5;
13
+ const SECONDARY_HEIGHT = 5.5;
14
+
15
+ /** Focus the first button under `scope` — the slot owns the element, so it is found, not referenced. */
16
+ function focusButtonIn(scope: HTMLElement | null): void {
17
+ scope?.querySelector<HTMLButtonElement>("button")?.focus();
18
+ }
19
+
20
+ /**
21
+ * The one list's bar (FUT-3591 #7, M12), top to bottom: the host's line
22
+ * (items and delivery fee), the host's online-only extras (store credit), who
23
+ * is paying, the ONE action — or what replaced it — and why it is held.
24
+ *
25
+ * Drawn through the host's `ActionBar` slot, which is what pins it to the
26
+ * bottom of the screen: the storefront passes its sticky action bar.
27
+ *
28
+ * ONE live region, always mounted, says what changes under the button — the
29
+ * reason it is held, the charge under way, the wait — because a region that
30
+ * mounts with its text already in it is announced by nobody.
31
+ */
32
+ export function ListBar({ summary, extras, payer, reason, live, children }: {
33
+ summary?: ReactNode;
34
+ extras?: ReactNode;
35
+ payer?: ReactNode;
36
+ /** Why the action is held, under it. */
37
+ reason?: string | null;
38
+ /** What the region announces when there is no reason: the bar's own sentence. */
39
+ live?: string | null;
40
+ /** The action, or the charge bar, or the wait. */
41
+ children: ReactNode;
42
+ }): JSX.Element {
43
+ const { ActionBar, Text } = useCheckoutComponents();
44
+ return (
45
+ <ActionBar dataTestId="list-pay-bar">
46
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1, width: "100%", minWidth: 0 }}>
47
+ {summary ? <Box data-testid="list-bar-summary">{summary}</Box> : null}
48
+ {extras ? <Box data-testid="list-bar-extras">{extras}</Box> : null}
49
+ {payer}
50
+ {children}
51
+ <Box role="status" aria-live="polite" data-testid="list-bar-live">
52
+ {reason ? (
53
+ <Text variant="body" size="sm" color="danger" as="p" style={{ margin: 0 }} data-testid="list-blocked-reason">
54
+ {reason}
55
+ </Text>
56
+ ) : (
57
+ <Box component="span" sx={{ position: "absolute", width: "1px", height: "1px", overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" }}>
58
+ {live ?? ""}
59
+ </Box>
60
+ )}
61
+ </Box>
62
+ </Box>
63
+ </ActionBar>
64
+ );
65
+ }
66
+
67
+ /** "Pagar" on the left, "R$ 22,00" on the right — the prototype's 52px bar of a button. */
68
+ export function ActionButton({ label, total, disabled, loading, onClick, focusOnMount = false }: {
69
+ label: string;
70
+ total: string | null;
71
+ disabled: boolean;
72
+ loading?: boolean;
73
+ onClick: () => void;
74
+ /** Take the focus as it appears — back from a cancelled charge, where the focus was on Cancelar. */
75
+ focusOnMount?: boolean;
76
+ }): JSX.Element {
77
+ const { Button } = useCheckoutComponents();
78
+ const scope = useRef<HTMLDivElement | null>(null);
79
+ useEffect(() => {
80
+ if (focusOnMount) focusButtonIn(scope.current);
81
+ }, [focusOnMount]);
82
+ return (
83
+ <Box ref={scope} sx={{ "& button": { minHeight: (t) => t.spacing(ACTION_HEIGHT) }, "& button, & button *": { fontWeight: 600 } }}>
84
+ <Button variant="solid" color="primary" size="lg" fullWidth disabled={disabled} loading={loading} onClick={onClick} dataTestId="list-pay">
85
+ {/* The amount never breaks ("R$ / 27,00"); the label may wrap, at the left. An action with
86
+ no amount ("Copiar código Pix") is centred (C20). */}
87
+ <Box component="span" sx={{ display: "flex", justifyContent: total ? "space-between" : "center", alignItems: "center", gap: 1, width: "100%" }}>
88
+ <Box component="span" sx={{ minWidth: 0, textAlign: total ? "left" : "center" }}>{label}</Box>
89
+ {total ? <Box component="span" sx={{ flex: "none", whiteSpace: "nowrap" }}>{total}</Box> : null}
90
+ </Box>
91
+ </Button>
92
+ </Box>
93
+ );
94
+ }
95
+
96
+ const fill = keyframes`from { width: 0; } to { width: 100%; }`;
97
+
98
+ /**
99
+ * The card's 5-second bar, in the button's own place (FUT-3592 #2): the
100
+ * sentence in white on the primary colour, a darker fill growing left to
101
+ * right, and "Cancelar" under it while there is still nothing sent — which
102
+ * takes the focus as it appears, since the button that had it is gone. `full`
103
+ * draws it filled, with no Cancelar — the charge is on its way.
104
+ */
105
+ export function ChargeBar({ text, full, cancelLabel, onCancel }: {
106
+ text: string;
107
+ full: boolean;
108
+ cancelLabel: string;
109
+ onCancel: () => void;
110
+ }): JSX.Element {
111
+ const { Button } = useCheckoutComponents();
112
+ const cancelScope = useRef<HTMLDivElement | null>(null);
113
+ useEffect(() => {
114
+ if (!full) focusButtonIn(cancelScope.current);
115
+ }, [full]);
116
+ return (
117
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
118
+ <Box
119
+ data-testid="list-charge-bar"
120
+ data-full={full ? "true" : "false"}
121
+ // White on the primary's DARK shade, and on a darker fill still, in
122
+ // either theme: `primary.contrastText` turns dark in a dark theme and
123
+ // falls under 4.5:1 on the fill (E13 says white).
124
+ sx={{ position: "relative", minHeight: (t) => t.spacing(ACTION_HEIGHT), borderRadius: 2, bgcolor: "primary.dark", overflow: "hidden", display: "flex", alignItems: "center", px: 2.25 }}
125
+ >
126
+ <Box
127
+ aria-hidden
128
+ sx={{
129
+ position: "absolute",
130
+ insetBlock: 0,
131
+ left: 0,
132
+ bgcolor: "common.black",
133
+ opacity: 0.3,
134
+ width: full ? "100%" : 0,
135
+ animation: full ? "none" : `${fill} ${CHARGE_WINDOW_MS}ms linear forwards`,
136
+ }}
137
+ />
138
+ <Box component="span" sx={{ position: "relative", color: "common.white", typography: "body1", fontWeight: 600 }}>
139
+ {text}
140
+ </Box>
141
+ </Box>
142
+ {full ? null : (
143
+ <Box ref={cancelScope} sx={{ "& button": { minHeight: (t) => t.spacing(SECONDARY_HEIGHT) } }}>
144
+ <Button variant="outline" color="primary" size="lg" fullWidth onClick={onCancel} dataTestId="list-charge-cancel">
145
+ {cancelLabel}
146
+ </Button>
147
+ </Box>
148
+ )}
149
+ </Box>
150
+ );
151
+ }
152
+
153
+ /** A muted, inert line in the action's place — "Aguardando o pagamento do Pix…" (the bar's live region says it). */
154
+ export function WaitingLine({ text }: { text: string }): JSX.Element {
155
+ const { Text } = useCheckoutComponents();
156
+ return (
157
+ <Box
158
+ data-testid="list-waiting"
159
+ sx={{ minHeight: (t) => t.spacing(SECONDARY_HEIGHT), borderRadius: 2, bgcolor: "action.hover", display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", px: 1 }}
160
+ >
161
+ <Text variant="body" size="sm" color="secondary" as="span">
162
+ {text}
163
+ </Text>
164
+ </Box>
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Google Pay's own button in the action's place (FUT-471's brand rule: the
170
+ * buyer presses Google's button, drawn by `createButton`, never a look-alike).
171
+ * Its press opens the sheet SYNCHRONOUSLY — inside the tap, as the browser
172
+ * requires — through `onPress`.
173
+ */
174
+ export function GooglePayAction({ client, onPress }: { client: GooglePaymentsClient; onPress: () => void }): JSX.Element {
175
+ const mount = useRef<HTMLDivElement | null>(null);
176
+ const latest = useRef(onPress);
177
+ latest.current = onPress;
178
+ const locale = useCheckoutCopy().screens.wallet.googlePay.buttonLocale;
179
+ useEffect(() => {
180
+ const element = mount.current;
181
+ if (!element) return undefined;
182
+ element.replaceChildren(client.createButton({ onClick: () => latest.current(), buttonSizeMode: "fill", buttonLocale: locale }));
183
+ return () => element.replaceChildren();
184
+ }, [client, locale]);
185
+ return <Box ref={mount} data-testid="list-google-pay" sx={{ minHeight: (t) => t.spacing(ACTION_HEIGHT), "& > *": { width: "100%" } }} />;
186
+ }
@@ -0,0 +1,315 @@
1
+ import { Box } from "@mui/material";
2
+ import { useCallback, useEffect, useRef, useState, type JSX } from "react";
3
+
4
+ import { cardChain, cardTokenization, handOffMethod } from "../method-capability";
5
+ import { paymentDataRequest, sheetDismissed } from "../google-pay-button";
6
+ import { PaymentErrorPanel } from "../payment-error-panel";
7
+ import { UNRESOLVED_CODE } from "../failure-codes";
8
+ import type { CheckoutBasketIdentity } from "../basket";
9
+ import type { BuyerContact, CheckoutProviderConfig } from "../types";
10
+ import type { useCheckoutController } from "../use-checkout-controller";
11
+ import { useCheckoutComponents } from "../ui";
12
+
13
+ import { actionLabel, blockedReason, cardName } from "./choice";
14
+ import { ChoiceList } from "./choice-list";
15
+ import { CardRun, WalletRun, type ListChargeFailure } from "./charge-runners";
16
+ import { ActionButton, ChargeBar, GooglePayAction, ListBar } from "./list-bar";
17
+ import type { ListCapability } from "./use-capability";
18
+ import type { ListCharge } from "./use-list-charge";
19
+ import type { ListState } from "./use-list-state";
20
+ import type { CheckoutDoorOption, CheckoutOnlineChoice, CheckoutPaymentList } from "./types";
21
+
22
+ type Controller = ReturnType<typeof useCheckoutController>;
23
+
24
+ /** Everything the list face reads, gathered by the step. */
25
+ interface ListFaceProps {
26
+ c: Controller;
27
+ list: CheckoutPaymentList;
28
+ state: ListState;
29
+ charge: ListCharge;
30
+ cap: ListCapability;
31
+ config: CheckoutProviderConfig | null;
32
+ /** The amount the buyer sees now — the cart's, or the balance's. Never a held order's. */
33
+ total: string;
34
+ provider: string | null;
35
+ taxIdOnFile: boolean;
36
+ tenantSlug: string | undefined;
37
+ basket: CheckoutBasketIdentity | undefined;
38
+ pollIntervalMs: number;
39
+ saveBuyerContact?: (contact: BuyerContact) => void;
40
+ /** The hand-off was started: the step shows where the buyer is going. */
41
+ onHandOff: () => void;
42
+ }
43
+
44
+ /** Google Pay's sheet, opened in the tap: its answer and the amount it showed. */
45
+ interface OpenSheet {
46
+ answer: Promise<string | null>;
47
+ cents: number;
48
+ }
49
+
50
+ function chosenDoor(props: ListFaceProps): CheckoutDoorOption | undefined {
51
+ const { choice } = props.state;
52
+ if (choice?.kind !== "door") return undefined;
53
+ return props.list.door?.find((option) => option.id === choice.id);
54
+ }
55
+
56
+ /** Something is on its way that a second press would duplicate: a raise, a host send, a charge. */
57
+ function inFlight(props: ListFaceProps): boolean {
58
+ const doorPending = props.list.door?.some((option) => option.pending) ?? false;
59
+ return props.c.creating || doorPending || props.charge.phase !== "idle";
60
+ }
61
+
62
+ /** Remember the asked details for next time, as the Dados step's "Continuar" did — only with the buyer's consent. */
63
+ function saveAsked(props: ListFaceProps): void {
64
+ const { c, state, saveBuyerContact } = props;
65
+ if (state.asked.length === 0 || !c.saveProfile) return;
66
+ saveBuyerContact?.({ name: c.buyer.name, phone: c.buyer.phone, taxId: c.buyer.taxId });
67
+ }
68
+
69
+ /** An online choice's press: the card goes through the bar, Pix and the hand-off raise at once. */
70
+ function actOnline(props: ListFaceProps, method: CheckoutOnlineChoice): void {
71
+ const { c, charge, cap } = props;
72
+ saveAsked(props);
73
+ if (method === "CARD") return charge.start("CARD", props.total);
74
+ const raised = method === "PIX" ? "PIX" : handOffMethod(cap.offered);
75
+ if (method === "HAND_OFF") props.onHandOff();
76
+ c.setMethod(raised);
77
+ void c.startPayment(raised);
78
+ }
79
+
80
+ /**
81
+ * The bar's ONE action (M7): the only thing in the list that raises an order
82
+ * or a charge. A door option hands the press to the host; the card counts five
83
+ * seconds first; everything else raises at once.
84
+ */
85
+ function useAct(props: ListFaceProps, clearFailure: () => void): () => void {
86
+ return () => {
87
+ const { choice } = props.state;
88
+ if (!choice || inFlight(props)) return;
89
+ if (choice.kind === "door") return chosenDoor(props)?.onSend();
90
+ clearFailure();
91
+ actOnline(props, choice.method);
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Google Pay's press, from Google's own button: the sheet opens HERE, inside
97
+ * the tap, priced with the host's total — then the order is raised behind it.
98
+ */
99
+ function useGooglePayPress(props: ListFaceProps, clearFailure: () => void, sheet: { current: OpenSheet | null }): () => void {
100
+ return () => {
101
+ const { cap, list, charge } = props;
102
+ const cents = list.totalCents;
103
+ if (!cap.googlePay || cents === undefined || inFlight(props)) return;
104
+ clearFailure();
105
+ saveAsked(props);
106
+ const answer = cap.googlePay.client
107
+ .loadPaymentData(paymentDataRequest(cap.googlePay.params, { totalCents: cents }))
108
+ .then(
109
+ (data) => data.paymentMethodData.tokenizationData.token,
110
+ (error: unknown) => {
111
+ if (sheetDismissed(error)) return null;
112
+ throw error;
113
+ },
114
+ );
115
+ sheet.current = { answer, cents };
116
+ charge.start("GOOGLE_PAY", props.total);
117
+ };
118
+ }
119
+
120
+ /** Who is paying, when the store already holds their CPF (FUT-465): "Pagando como … · CPF ✓ Alterar". */
121
+ function PayerLine({ props }: { props: ListFaceProps }): JSX.Element | null {
122
+ const { Button, Text } = useCheckoutComponents();
123
+ const { c, list, taxIdOnFile, state } = props;
124
+ const name = c.buyer.name?.trim();
125
+ if (!taxIdOnFile || !name || state.choice?.kind !== "online") return null;
126
+ return (
127
+ // Left-aligned, "Alterar" ~4px after "CPF ✓" and wrapping under it when
128
+ // the name is long; the link keeps a 40px target and a visible focus ring.
129
+ <Box data-testid="list-payer" sx={{ display: "flex", alignItems: "center", justifyContent: "flex-start", flexWrap: "wrap", columnGap: 0 }}>
130
+ <Text variant="body" size="sm" color="secondary" as="span">
131
+ {list.copy.payer(name)}
132
+ </Text>
133
+ {c.editBuyer ? (
134
+ <Box
135
+ sx={{
136
+ "& button": { textDecoration: "underline", minHeight: (t) => t.spacing(5), minWidth: 0, px: 0.5, py: 0 },
137
+ "& button:focus-visible": { outline: (t) => `${t.spacing(0.25)} solid ${t.palette.primary.main}`, outlineOffset: (t) => t.spacing(0.25) },
138
+ }}
139
+ >
140
+ <Button variant="text" color="primary" size="sm" onClick={c.editBuyer} dataTestId="list-payer-change">
141
+ {list.copy.payerChange}
142
+ </Button>
143
+ </Box>
144
+ ) : null}
145
+ </Box>
146
+ );
147
+ }
148
+
149
+ /** The card's charge is past the button: counting down, or going out. */
150
+ function cardCharging(charge: ListCharge): boolean {
151
+ return charge.phase === "counting" || (charge.phase === "sending" && charge.kind === "CARD");
152
+ }
153
+
154
+ /** The bar's sentence: the amount frozen at the press, and the card it goes on. */
155
+ function chargingText(props: ListFaceProps): string {
156
+ const { charge, list, state, total } = props;
157
+ const selected = state.card.saved.find((card) => card.id === state.card.selection);
158
+ return list.copy.charging(charge.pressedTotal ?? total, selected ? cardName(list.copy, selected) : null);
159
+ }
160
+
161
+ /** The verb, the amount and the busy state of the button, for what is chosen. */
162
+ function buttonFacts(props: ListFaceProps): { label: string; total: string; busy: boolean } {
163
+ const { list, state } = props;
164
+ const door = chosenDoor(props);
165
+ return {
166
+ label: list.hold ? list.hold.action : actionLabel(list.copy, state.choice, door, props.provider),
167
+ total: door?.totalLabel ?? props.total,
168
+ busy: inFlight(props),
169
+ };
170
+ }
171
+
172
+ /** Google Pay chosen while the host re-prices: the row stays, its button waits for the price. */
173
+ function googlePayRepricing(props: ListFaceProps): boolean {
174
+ const { choice } = props.state;
175
+ return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && !props.cap.googlePay;
176
+ }
177
+
178
+ /** Google Pay is chosen and nothing holds it: Google's own button takes the action's place. */
179
+ function googlePayLive(props: ListFaceProps, reason: string | null, busy: boolean): boolean {
180
+ const { choice } = props.state;
181
+ return choice?.kind === "online" && choice.method === "GOOGLE_PAY" && props.cap.googlePay !== null && reason === null && !busy;
182
+ }
183
+
184
+ /** The button, the 5-second bar, Google's own button, or nothing for a charge nobody can confirm yet. */
185
+ function BarAction({ props, failure, act, onGooglePay, onCancel, reason, returnFocus }: {
186
+ props: ListFaceProps;
187
+ failure: ListChargeFailure | null;
188
+ act: () => void;
189
+ onGooglePay: () => void;
190
+ onCancel: () => void;
191
+ reason: string | null;
192
+ returnFocus: boolean;
193
+ }): JSX.Element | null {
194
+ const { charge, list, cap } = props;
195
+ if (cardCharging(charge)) {
196
+ return <ChargeBar text={chargingText(props)} full={charge.phase !== "counting"} cancelLabel={list.copy.cancel} onCancel={onCancel} />;
197
+ }
198
+ // An UNRESOLVED charge may be holding the buyer's money: no pay control at
199
+ // all under "não pague de novo" (FUT-563), as the card pane does today.
200
+ if (failure?.code === UNRESOLVED_CODE) return null;
201
+ const { label, total, busy } = buttonFacts(props);
202
+ if (googlePayRepricing(props)) return <ActionButton label={label} total={total} disabled loading onClick={act} />;
203
+ if (googlePayLive(props, reason, busy) && cap.googlePay) return <GooglePayAction client={cap.googlePay.client} onPress={onGooglePay} />;
204
+ return <ActionButton label={label} total={total} disabled={reason !== null || busy} loading={busy} onClick={act} focusOnMount={returnFocus} />;
205
+ }
206
+
207
+ /** The charge under way, once there is an order to run it against. */
208
+ function Runners({ props, sheet, onFailed }: {
209
+ props: ListFaceProps;
210
+ sheet: OpenSheet | null;
211
+ onFailed: (failure: ListChargeFailure | null) => void;
212
+ }): JSX.Element | null {
213
+ const { c, charge, config } = props;
214
+ const order = charge.chargeable;
215
+ if (!order) return null;
216
+ const common = {
217
+ order, buyer: c.buyer, scope: { tenantSlug: props.tenantSlug, basket: props.basket },
218
+ onResolved: c.handleResolved, pollIntervalMs: props.pollIntervalMs, onAccepted: charge.accepted, onFailed,
219
+ };
220
+ if (charge.kind === "CARD") {
221
+ return <CardRun {...common} card={props.state.card} config={cardTokenization(config)} chain={cardChain(config)} />;
222
+ }
223
+ if (charge.kind === "GOOGLE_PAY" && sheet) return <WalletRun {...common} sheet={sheet.answer} sheetCents={sheet.cents} />;
224
+ return null;
225
+ }
226
+
227
+ /** A raise the host refused, with the step's own retry — as the tabs layout shows it. */
228
+ function RaiseError({ c, onRetry }: { c: Controller; onRetry: () => void }): JSX.Element | null {
229
+ if (!c.createError || c.order || c.creating) return null;
230
+ return <PaymentErrorPanel message={c.createError} emailFlagged={c.errorField === "email"} code={c.errorCode} onUseEmail={c.payWithEmail} onRetry={onRetry} />;
231
+ }
232
+
233
+ /** Why the button is held: the host's hold, then the list's own reasons, in the prototype's order. */
234
+ function reasonOf(props: ListFaceProps, door: CheckoutDoorOption | undefined): string | null {
235
+ const { c, list, state } = props;
236
+ return blockedReason({
237
+ copy: list.copy, hold: list.hold?.reason ?? null, choice: state.choice, door, asked: state.asked,
238
+ buyer: c.buyer, selection: state.card.selection, cardIsComplete: state.card.complete,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * While the bar counts, a charge goes out, a raise or a host send is under way,
244
+ * only Cancelar acts: the list is inert, and the credit and the payer line
245
+ * step aside (FN:587,590).
246
+ */
247
+ function barFacts(props: ListFaceProps, door: CheckoutDoorOption | undefined, reason: string | null) {
248
+ const charging = props.charge.phase !== "idle";
249
+ const online = props.state.choice?.kind === "online";
250
+ return {
251
+ charging,
252
+ locked: inFlight(props),
253
+ extras: online && !charging ? props.list.onlineExtras : undefined,
254
+ underButton: charging ? null : (door?.error ?? reason),
255
+ live: cardCharging(props.charge) ? chargingText(props) : null,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * The bar may still charge only while nothing holds it and the amount it named
261
+ * at the press is still the amount — a hold the host raises, a reason that
262
+ * appears or a total that moves mid-bar stops it, and `send` checks again.
263
+ */
264
+ function useChargeGuard(props: ListFaceProps, reason: string | null): void {
265
+ const { charge, total } = props;
266
+ const allowed = reason === null && (charge.pressedTotal === null || charge.pressedTotal === total);
267
+ const { setAllowed } = charge;
268
+ useEffect(() => setAllowed(allowed), [allowed, setAllowed]);
269
+ }
270
+
271
+ /** Cancelar by the buyer gives the focus back to the button it replaced. */
272
+ function useCancelWithFocus(charge: ListCharge): { returnFocus: boolean; onCancel: () => void } {
273
+ const [returnFocus, setReturnFocus] = useState(false);
274
+ useEffect(() => {
275
+ if (returnFocus && charge.phase === "idle") setReturnFocus(false);
276
+ }, [returnFocus, charge.phase]);
277
+ const { cancel } = charge;
278
+ const onCancel = useCallback(() => {
279
+ setReturnFocus(true);
280
+ cancel();
281
+ }, [cancel]);
282
+ return { returnFocus, onCancel };
283
+ }
284
+
285
+ /** The list, the charge under way, and the bar. */
286
+ export function ListFace(props: ListFaceProps): JSX.Element {
287
+ const { c, list, state, charge } = props;
288
+ const [failure, setFailure] = useState<ListChargeFailure | null>(null);
289
+ const clearFailure = useCallback(() => setFailure(null), []);
290
+ const { settle } = charge;
291
+ const onFailed = useCallback((next: ListChargeFailure | null) => { setFailure(next); settle(); }, [settle]);
292
+ const sheet = useRef<OpenSheet | null>(null);
293
+ const act = useAct(props, clearFailure);
294
+ const onGooglePay = useGooglePayPress(props, clearFailure, sheet);
295
+ const door = chosenDoor(props);
296
+ const reason = reasonOf(props, door);
297
+ useChargeGuard(props, reason);
298
+ const { returnFocus, onCancel } = useCancelWithFocus(charge);
299
+ const shown = charge.phase !== "waiting";
300
+ const facts = barFacts(props, door, reason);
301
+ return (
302
+ <Box data-testid="payment-list-step" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
303
+ {shown ? (
304
+ <ChoiceList copy={list.copy} state={state} door={list.door ?? []} buyer={c.buyer} onBuyerChange={c.setBuyer} provider={props.provider} failure={failure} locked={facts.locked} />
305
+ ) : null}
306
+ <Runners props={props} sheet={sheet.current} onFailed={onFailed} />
307
+ <RaiseError c={c} onRetry={act} />
308
+ {shown ? (
309
+ <ListBar summary={list.summary} extras={facts.extras} payer={facts.charging ? null : <PayerLine props={props} />} reason={facts.underButton} live={facts.live}>
310
+ <BarAction props={props} failure={failure} act={act} onGooglePay={onGooglePay} onCancel={onCancel} reason={reason} returnFocus={returnFocus} />
311
+ </ListBar>
312
+ ) : null}
313
+ </Box>
314
+ );
315
+ }