@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 +1 -1
- package/src/components/checkout/list/choice-list.tsx +3 -0
- package/src/components/checkout/list/cpf-top.tsx +24 -12
- package/src/components/checkout/list/credit-types.ts +27 -0
- package/src/components/checkout/list/en-US.ts +1 -0
- package/src/components/checkout/list/lazy-body.tsx +118 -0
- package/src/components/checkout/list/list-bar.tsx +12 -6
- package/src/components/checkout/list/list-face.tsx +6 -3
- package/src/components/checkout/list/list-top-body.tsx +40 -0
- package/src/components/checkout/list/list-top.tsx +88 -34
- package/src/components/checkout/list/option-row.tsx +7 -3
- package/src/components/checkout/list/payer-line.tsx +3 -1
- package/src/components/checkout/list/payment-list-step.tsx +30 -1
- package/src/components/checkout/list/pt-BR.ts +1 -0
- package/src/components/checkout/list/summary-column-body.tsx +61 -0
- package/src/components/checkout/list/summary-column.tsx +66 -44
- package/src/components/checkout/list/types.ts +4 -4
- package/src/components/checkout/list/use-list-state.ts +14 -3
- package/src/components/checkout/ui.tsx +19 -1
- package/src/index.ts +2 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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%",
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
{
|
|
50
|
-
{
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
-
<
|
|
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 {
|
|
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 {
|
|
8
|
-
import { cpfHolds
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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)
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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({
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
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
|
-
):
|
|
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.
|