@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,119 @@
1
+ import type { JSX } from "react";
2
+
3
+ import type { CheckoutCartView, CheckoutFlowProps } from "./checkout-flow";
4
+ import { PaymentStep } from "./checkout-steps";
5
+ import { PaymentListStep } from "./list/payment-list-step";
6
+ import { OneClickProvider } from "./one-click";
7
+ import type { CheckoutProviderConfig, SettlementCheckout } from "./types";
8
+ import type { useCheckoutController } from "./use-checkout-controller";
9
+
10
+ /**
11
+ * Step 2 of `<CheckoutFlow>`, moved out of `./checkout-flow.tsx` at its size
12
+ * gate when the Pagamento step gained a second shape (FUT-3591): the method
13
+ * tabs it always had, or the one list a host opts into.
14
+ */
15
+
16
+ /** The pay-bar total override when settling a settlement (else the cart's own totals). */
17
+ export function settlementTotalOverride(
18
+ settlement: SettlementCheckout | null | undefined,
19
+ ): { label: string; items: number } | undefined {
20
+ return settlement ? { label: settlement.totalLabel, items: settlement.totalItems } : undefined;
21
+ }
22
+
23
+ /** Step 2, with the controller's facts and the host's money mapped onto it. */
24
+ function PagamentoStep({
25
+ c,
26
+ cart,
27
+ settlement,
28
+ providerConfig,
29
+ tenantSlug,
30
+ validateApplePayMerchant,
31
+ }: {
32
+ c: ReturnType<typeof useCheckoutController>;
33
+ cart: CheckoutCartView;
34
+ settlement: SettlementCheckout | null | undefined;
35
+ providerConfig: CheckoutProviderConfig | null | undefined;
36
+ tenantSlug: string | undefined;
37
+ validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
38
+ }): JSX.Element {
39
+ return (
40
+ <PaymentStep
41
+ method={c.method}
42
+ onMethodChange={c.setMethod}
43
+ order={c.order}
44
+ buyer={c.buyer}
45
+ creating={c.creating}
46
+ createError={c.createError}
47
+ errorField={c.errorField}
48
+ errorCode={c.errorCode}
49
+ onGenerate={(chosen) => void c.startPayment(chosen)}
50
+ refresh={c.refresh}
51
+ onUseEmail={c.payWithEmail}
52
+ // Set only for a skipped-Dados flow (the controller decides); the payer
53
+ // block hides itself when it is absent.
54
+ onEditBuyer={c.editBuyer}
55
+ providerConfig={providerConfig}
56
+ tenantSlug={tenantSlug}
57
+ // The amount, on the step that asks for it (FUT-1179).
58
+ cartTotals={cart}
59
+ totalOverride={settlementTotalOverride(settlement)}
60
+ discountLines={cart.discountLines}
61
+ // The order around the payment, for the summary (FUT-3525).
62
+ lines={cart.lines}
63
+ delivery={cart.delivery}
64
+ orderNote={cart.orderNote}
65
+ // Retrying a refused card: the failed saved card is not chosen again (FUT-1145).
66
+ freshInstrument={c.freshInstrument}
67
+ // The card path parks an order of its own for a 3-D Secure challenge, so
68
+ // it needs the same basket the flow was mounted for (FUT-1213).
69
+ basket={cart.identity}
70
+ validateApplePayMerchant={validateApplePayMerchant}
71
+ onResolved={c.handleResolved}
72
+ />
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Step 2, in whichever shape the host asked for: the one list, or the tabs.
78
+ * The list never arms one-click — it raises nothing on its own, which is the
79
+ * one-click request's ordinary degraded outcome anyway.
80
+ */
81
+ export function PaymentPhase({
82
+ props,
83
+ c,
84
+ armed,
85
+ }: {
86
+ props: Omit<CheckoutFlowProps, "components">;
87
+ c: ReturnType<typeof useCheckoutController>;
88
+ armed: boolean;
89
+ }): JSX.Element {
90
+ const { cart, settlement, providerConfig, tenantSlug, validateApplePayMerchant, paymentList } = props;
91
+ if (paymentList) {
92
+ return (
93
+ <PaymentListStep
94
+ c={c}
95
+ list={paymentList}
96
+ cart={cart}
97
+ totalOverride={settlementTotalOverride(settlement)}
98
+ providerConfig={providerConfig}
99
+ tenantSlug={tenantSlug}
100
+ taxIdOnFile={props.taxIdOnFile ?? false}
101
+ defaultBuyer={props.defaultBuyer}
102
+ saveBuyerContact={props.saveBuyerContact}
103
+ consentLabel={props.copy.dados.saveProfile}
104
+ />
105
+ );
106
+ }
107
+ return (
108
+ <OneClickProvider armed={armed}>
109
+ <PagamentoStep
110
+ c={c}
111
+ cart={cart}
112
+ settlement={settlement}
113
+ providerConfig={providerConfig}
114
+ tenantSlug={tenantSlug}
115
+ validateApplePayMerchant={validateApplePayMerchant}
116
+ />
117
+ </OneClickProvider>
118
+ );
119
+ }
@@ -1,9 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
+ import { usePaymentStale } from "./cart-changed";
4
5
  import { useCheckoutCopy } from "./copy-context";
5
6
  import { CheckIcon, ContentCopyIcon, CreditCardIcon, QrCodeIcon, ScheduleIcon } from "./icons";
6
- import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
7
+ import { Emphasized, PIX_WIDE, PixQr, StaleVeil } from "./pix-parts";
7
8
  import type { PixAfterCopyCopy } from "./screens-copy";
8
9
  import type { PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
@@ -128,11 +129,14 @@ interface PixAfterCopyProps {
128
129
  function Actions({ justCopied, onCopyAgain, onShowQr, onPreferCard }: Pick<PixAfterCopyProps, "justCopied" | "onCopyAgain" | "onShowQr" | "onPreferCard">): JSX.Element {
129
130
  const { Button } = useCheckoutComponents();
130
131
  const copy = useCheckoutCopy().screens.pix;
132
+ // The code already copied is the OLD order's once the cart changed
133
+ // (FUT-3535): copying it again would only hand the buyer the wrong amount.
134
+ const stale = usePaymentStale();
131
135
  return (
132
136
  <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 1.25, "& button": { minHeight: 48, height: "100%" }, "& button, & button *": { fontWeight: 700 }, [PIX_WIDE]: { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1.5, "& button": { height: "auto" } } }}>
133
137
  {/* Narrow, a grid cell; wide, its own width beside the link (the prototype's row). */}
134
138
  <Box sx={{ minWidth: 0, [PIX_WIDE]: { flex: "none", "& > *": { width: "auto" } } }}>
135
- <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} dataTestId="pix-copy-again">
139
+ <Button variant="outline" color="neutral" size="md" fullWidth icon={<ContentCopyIcon fontSize="small" />} onClick={onCopyAgain} disabled={stale} dataTestId="pix-copy-again">
136
140
  {justCopied ? copy.copiedAction : copy.copyAgainAction}
137
141
  </Button>
138
142
  </Box>
@@ -156,6 +160,7 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
156
160
  const { Button, Text } = useCheckoutComponents();
157
161
  const copy = useCheckoutCopy().screens.pix;
158
162
  const { pix, words, totalLabel, deadline, onPreferCard, footer } = props;
163
+ const stale = usePaymentStale();
159
164
  return (
160
165
  <Box data-testid="pix-after-copy" sx={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "flex-start" }}>
161
166
  <Box sx={{ flex: "999 1 420px", minWidth: 0, display: "flex", flexDirection: "column", gap: 2.5 }}>
@@ -175,7 +180,9 @@ export function PixAfterCopy(props: PixAfterCopyProps): JSX.Element {
175
180
  <Text variant="body" size="xs" weight="bold" color="secondary" as="p" style={{ margin: 0, textTransform: "uppercase", letterSpacing: "0.04em" }}>
176
181
  {copy.notPaidYet}
177
182
  </Text>
178
- <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
183
+ <StaleVeil stale={stale}>
184
+ <PixQr payload={pix.copyPaste} size={168} alt={copy.qrAlt} />
185
+ </StaleVeil>
179
186
  <Text variant="body" size="xs" color="secondary" as="p" style={{ margin: 0, textAlign: "center" }}>
180
187
  {copy.scanFromPhone}
181
188
  </Text>
@@ -1,10 +1,11 @@
1
1
  import { Box } from "@mui/material";
2
2
  import type { JSX, ReactNode } from "react";
3
3
 
4
+ import { usePaymentStale } from "./cart-changed";
4
5
  import { useCheckoutCopy } from "./copy-context";
5
6
  import { ContentCopyIcon } from "./icons";
6
7
  import { StickyPayBar, useCheckoutLayout } from "./payment-step-layout";
7
- import { Emphasized, PIX_WIDE, PixQr } from "./pix-parts";
8
+ import { Emphasized, PIX_WIDE, PixQr, StaleVeil } from "./pix-parts";
8
9
  import type { PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
10
11
 
@@ -64,6 +65,7 @@ function ColumnHeading({
64
65
  function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
65
66
  const { Text } = useCheckoutComponents();
66
67
  const copy = useCheckoutCopy().screens.pix;
68
+ const stale = usePaymentStale();
67
69
  return (
68
70
  <Box
69
71
  data-testid="pix-qr-panel"
@@ -100,12 +102,14 @@ function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
100
102
  [PIX_WIDE]: { "& svg": { width: 232, height: 232 } },
101
103
  }}
102
104
  >
103
- <PixQr
104
- payload={pix.copyPaste}
105
- size={232}
106
- alt={copy.qrAlt}
107
- testId="pix-qr"
108
- />
105
+ <StaleVeil stale={stale}>
106
+ <PixQr
107
+ payload={pix.copyPaste}
108
+ size={232}
109
+ alt={copy.qrAlt}
110
+ testId="pix-qr"
111
+ />
112
+ </StaleVeil>
109
113
  </Box>
110
114
  <Text variant="body" size="sm" color="secondary" as="p" style={{ margin: 0, maxWidth: 280 }}>
111
115
  <Box component="span" sx={NARROW_ONLY}>{copy.qrTabCaption}</Box>
@@ -125,6 +129,9 @@ function QrPanel({ pix }: { pix: PixCharge }): JSX.Element {
125
129
  function CopyButton({ onCopy }: { onCopy: () => void }): JSX.Element {
126
130
  const { Button } = useCheckoutComponents();
127
131
  const copy = useCheckoutCopy().screens.pix;
132
+ // A changed cart makes this code the OLD order's (FUT-3535): nothing to copy
133
+ // until the step's "Atualizar pagamento" brings a new one.
134
+ const stale = usePaymentStale();
128
135
  return (
129
136
  <StickyPayBar>
130
137
  <Box
@@ -141,6 +148,7 @@ function CopyButton({ onCopy }: { onCopy: () => void }): JSX.Element {
141
148
  fullWidth
142
149
  icon={<ContentCopyIcon fontSize="small" />}
143
150
  onClick={onCopy}
151
+ disabled={stale}
144
152
  dataTestId="pix-copy"
145
153
  >
146
154
  {copy.copyAction}
@@ -162,6 +170,7 @@ function CopyPanel({
162
170
  }): JSX.Element {
163
171
  const { Text } = useCheckoutComponents();
164
172
  const copy = useCheckoutCopy().screens.pix;
173
+ const stale = usePaymentStale();
165
174
  return (
166
175
  <Box
167
176
  data-testid="pix-copy-panel"
@@ -179,21 +188,23 @@ function CopyPanel({
179
188
  (a tinted chip, its own border and padding) is undone inside it:
180
189
  otherwise a wrapped code reads as a box inside a box.
181
190
  */}
182
- <Box
183
- sx={{
184
- px: 2,
185
- py: 1.5,
186
- border: "1px solid",
187
- borderColor: "divider",
188
- borderRadius: 1.5,
189
- bgcolor: "action.hover",
190
- "&& > *": { bgcolor: "transparent", border: 0, borderRadius: 0, p: 0 },
191
- }}
192
- >
193
- <Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
194
- {pix.copyPaste}
195
- </Text>
196
- </Box>
191
+ <StaleVeil stale={stale}>
192
+ <Box
193
+ sx={{
194
+ px: 2,
195
+ py: 1.5,
196
+ border: "1px solid",
197
+ borderColor: "divider",
198
+ borderRadius: 1.5,
199
+ bgcolor: "action.hover",
200
+ "&& > *": { bgcolor: "transparent", border: 0, borderRadius: 0, p: 0 },
201
+ }}
202
+ >
203
+ <Text variant="code" size="xs" as="p" data-testid="pix-code" style={{ margin: 0, wordBreak: "break-all", lineHeight: 1.55 }}>
204
+ {pix.copyPaste}
205
+ </Text>
206
+ </Box>
207
+ </StaleVeil>
197
208
  {inPanel ? <CopyButton onCopy={onCopy} /> : null}
198
209
  <Text
199
210
  variant="body"
@@ -1,7 +1,9 @@
1
1
  import { Box } from "@mui/material";
2
- import { Fragment, useCallback, useEffect, useRef, useState, type JSX } from "react";
2
+ import { Fragment, useCallback, useEffect, useRef, useState, type JSX, type ReactNode } from "react";
3
3
  import QRCode from "react-qr-code";
4
4
 
5
+ import { useCheckoutCopy } from "./copy-context";
6
+
5
7
  /**
6
8
  * The pieces the PIX pane and its after-copy face share (the 2026-10-06 Pix
7
9
  * redesign): the container-query switch, the QR, bold spans in a sentence, and
@@ -79,3 +81,50 @@ export function useClipboardCopy(text: string): { copy: () => Promise<boolean>;
79
81
  }, [text]);
80
82
  return { copy, justCopied };
81
83
  }
84
+
85
+ /**
86
+ * A PIX's QR or code, veiled while the cart has changed under it (FUT-3535):
87
+ * blurred past reading, out of reach of a pointer, a selection and a screen
88
+ * reader, with "Atualize para gerar um novo Pix" over it. The step's warning
89
+ * above says why and carries the control that brings a new code. One tree
90
+ * shape either way, so lifting the veil redraws nothing.
91
+ */
92
+ export function StaleVeil({ stale, children }: { stale: boolean; children: ReactNode }): JSX.Element {
93
+ const words = useCheckoutCopy().screens.cartChanged.pixVeil;
94
+ return (
95
+ <Box sx={{ position: "relative", minWidth: 0 }} data-testid={stale ? "pix-stale-veil" : undefined}>
96
+ <Box
97
+ aria-hidden={stale || undefined}
98
+ sx={stale ? { filter: "blur(6px)", opacity: 0.5, pointerEvents: "none", userSelect: "none" } : undefined}
99
+ >
100
+ {children}
101
+ </Box>
102
+ {stale ? (
103
+ <Box
104
+ sx={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", p: 1.5 }}
105
+ >
106
+ <Box
107
+ component="p"
108
+ // An opaque overlay over the blurred code (ADR 0121): it covers
109
+ // what is under it on purpose, so the review probe reads it so.
110
+ data-ui-overlay=""
111
+ sx={{
112
+ m: 0,
113
+ px: 1.5,
114
+ py: 0.75,
115
+ borderRadius: 999,
116
+ bgcolor: "background.paper",
117
+ boxShadow: 1,
118
+ color: "text.primary",
119
+ fontSize: "0.8125rem",
120
+ fontWeight: 600,
121
+ textAlign: "center",
122
+ }}
123
+ >
124
+ {words}
125
+ </Box>
126
+ </Box>
127
+ ) : null}
128
+ </Box>
129
+ );
130
+ }
@@ -2,6 +2,8 @@ import { Box } from "@mui/material";
2
2
  import { useEffect, useMemo, useRef, type JSX } from "react";
3
3
 
4
4
  import { useCheckoutCopy } from "./copy-context";
5
+ import { usePaymentListMode } from "./list/context";
6
+ import { ListPixFace } from "./list/list-pix";
5
7
  import { PixAfterCopy } from "./pix-after-copy";
6
8
  import { PixPane } from "./pix-pane";
7
9
  import { useCheckoutLayout } from "./payment-step-layout";
@@ -221,15 +223,19 @@ function PixTitle(): JSX.Element | null {
221
223
  );
222
224
  }
223
225
 
224
- function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; poll: ReturnType<typeof usePixPolling> }): JSX.Element {
225
- const copy = useCheckoutCopy().screens.pix;
226
- const stage = usePixStage();
227
- const { copy: writeClipboard, justCopied } = useClipboardCopy(pix.copyPaste);
228
- // Leaving the payment step (a method switch, a terminal status) ends the
229
- // after-copy face with it: the next pane starts on the code.
230
- const { setAfterCopy } = stage;
231
- // A clipboard write that settles after the pane is gone must not reopen the
232
- // after-copy face over whatever replaced it (it would hide the picker).
226
+ /**
227
+ * The clipboard, and the copy that moves the pane on to its after-copy face.
228
+ *
229
+ * Leaving the payment step (a method switch, a terminal status) ends the
230
+ * after-copy face with it: the next pane starts on the code. And a clipboard
231
+ * write that settles after the pane is gone must not reopen the after-copy
232
+ * face over whatever replaced it (it would hide the picker).
233
+ */
234
+ function useCopyThenWait(
235
+ payload: string,
236
+ setAfterCopy: (afterCopy: boolean) => void,
237
+ ): { writeClipboard: () => Promise<boolean>; justCopied: boolean; copyThenWait: () => void } {
238
+ const { copy: writeClipboard, justCopied } = useClipboardCopy(payload);
233
239
  const mounted = useRef(true);
234
240
  useEffect(() => {
235
241
  mounted.current = true;
@@ -238,6 +244,22 @@ function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; p
238
244
  setAfterCopy(false);
239
245
  };
240
246
  }, [setAfterCopy]);
247
+ // Only a copy the clipboard TOOK moves on: the next face opens on "Código
248
+ // Pix copiado", and a failed write leaves the code on screen to select by hand.
249
+ const copyThenWait = (): void => {
250
+ void writeClipboard().then((ok) => {
251
+ if (ok && mounted.current) setAfterCopy(true);
252
+ });
253
+ };
254
+ return { writeClipboard, justCopied, copyThenWait };
255
+ }
256
+
257
+ function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; poll: ReturnType<typeof usePixPolling> }): JSX.Element {
258
+ const copy = useCheckoutCopy().screens.pix;
259
+ const stage = usePixStage();
260
+ const listMode = usePaymentListMode();
261
+ const { writeClipboard, justCopied, copyThenWait } = useCopyThenWait(pix.copyPaste, stage.setAfterCopy);
262
+ const { setAfterCopy } = stage;
241
263
 
242
264
  // The locale is the HOST's (FUT-760): it decides what a buyer reads off the
243
265
  // clock, so it travels with the sentence it feeds rather than being frozen
@@ -247,6 +269,27 @@ function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; p
247
269
  const footerLine = (awaiting: string): JSX.Element => (
248
270
  <PixPollFooter error={poll.error} timedOut={poll.timedOut} onCheckAgain={poll.checkAgain} awaiting={awaiting} />
249
271
  );
272
+ // Inside the one list (FUT-3592), the code itself is the pane and the bar
273
+ // carries the copy; the wait is said by the bar, so only a STALLED poll
274
+ // draws here.
275
+ if (listMode) {
276
+ return (
277
+ <ListPixFace
278
+ mode={listMode}
279
+ order={order}
280
+ pix={pix}
281
+ time={time}
282
+ afterCopy={stage.afterCopy}
283
+ words={sentences.afterCopy}
284
+ justCopied={justCopied}
285
+ onCopy={copyThenWait}
286
+ onCopyAgain={() => void writeClipboard()}
287
+ onShowQr={() => setAfterCopy(false)}
288
+ onPreferCard={stage.preferCard}
289
+ footer={poll.error || poll.timedOut ? footerLine(copy.verifying) : null}
290
+ />
291
+ );
292
+ }
250
293
 
251
294
  if (stage.afterCopy) {
252
295
  return (
@@ -267,17 +310,7 @@ function PixBody({ order, pix, poll }: { order: CheckoutOrder; pix: PixCharge; p
267
310
  return (
268
311
  <>
269
312
  <PixTitle />
270
- <PixPane
271
- pix={pix}
272
- onCopy={() => {
273
- // Only a copy the clipboard TOOK moves on: the next face opens on
274
- // "Código Pix copiado", and a failed write leaves the code on screen
275
- // to select by hand.
276
- void writeClipboard().then((ok) => {
277
- if (ok && mounted.current) setAfterCopy(true);
278
- });
279
- }}
280
- />
313
+ <PixPane pix={pix} onCopy={copyThenWait} />
281
314
  <PixFooter deadline={sentences.validUntil(time)}>{footerLine(sentences.awaiting)}</PixFooter>
282
315
  </>
283
316
  );
@@ -284,6 +284,12 @@ export interface CartChangedCopy {
284
284
  refreshAction: string;
285
285
  /** The heading over a refresh that failed; the host's own sentence is the body. */
286
286
  refreshFailed: string;
287
+ /**
288
+ * Drawn over a PIX's QR and code while they are veiled (FUT-3535): the code
289
+ * on screen asks for the old amount, so it cannot be copied or scanned until
290
+ * the payment is updated.
291
+ */
292
+ pixVeil: string;
287
293
  }
288
294
 
289
295
  /** What the browser says when the wire itself failed. */
@@ -159,6 +159,7 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
159
159
  support: 'This payment was prepared for your previous cart. Update it to pay the right amount.',
160
160
  refreshAction: 'Update payment',
161
161
  refreshFailed: 'Could not update the payment',
162
+ pixVeil: 'Update to get a new Pix code',
162
163
  },
163
164
  validation: {
164
165
  taxIdInvalid: 'That CPF is not valid.',
@@ -145,6 +145,7 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
145
145
  support: 'O pagamento foi preparado para o carrinho anterior. Atualize para pagar o valor certo.',
146
146
  refreshAction: 'Atualizar pagamento',
147
147
  refreshFailed: 'Não foi possível atualizar o pagamento',
148
+ pixVeil: 'Atualize para gerar um novo Pix',
148
149
  },
149
150
  validation: {
150
151
  taxIdInvalid: 'CPF inválido.',
@@ -4,7 +4,6 @@ import {
4
4
  NEW_CARD,
5
5
  detectBrand,
6
6
  onlyDigits,
7
- tokenizerFor,
8
7
  validateCardNumber,
9
8
  validateCvv,
10
9
  validateExpiry,
@@ -23,6 +22,7 @@ import {
23
22
  type RefreshBrowserKey,
24
23
  } from "./card-instruments";
25
24
  import { handOverToChallenge, reportResolved, type ChallengeScope } from "./card-outcome";
25
+ import { useCardPublicKey, useSavedCards } from "./card-sources";
26
26
  import { useCheckoutClientApi } from "./client-context";
27
27
  import { useCheckoutNavigate } from "./navigate-context";
28
28
  import type { CardChainLink } from "./method-capability";
@@ -34,80 +34,6 @@ import { useCheckoutCopy } from "./copy-context";
34
34
 
35
35
  const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
36
36
 
37
- /**
38
- * Saved-card list + current selection, loaded once on mount — scoped to the
39
- * current store (FUT-697), so only cards the store's active provider can
40
- * actually charge are offered. The slug arrives as an argument (the host's
41
- * routing owns it); absent, the list is unscoped, exactly as before FUT-697.
42
- */
43
- function useSavedCards(
44
- tenantSlug: string | undefined,
45
- /**
46
- * The buyer is here because a card was REFUSED (FUT-1145), so the saved card
47
- * this would otherwise preselect is the one that just failed. The list is
48
- * still offered — another saved card may well work — but nothing is chosen
49
- * for them, which puts the form in front of a buyer whose only untried
50
- * instrument is a new one.
51
- */
52
- freshInstrument: boolean,
53
- ): {
54
- savedCards: SavedCard[];
55
- selection: string;
56
- setSelection: (id: string) => void;
57
- } {
58
- const [savedCards, setSavedCards] = useState<SavedCard[]>([]);
59
- const [selection, setSelection] = useState<string>(NEW_CARD);
60
- const client = useCheckoutClientApi();
61
-
62
- useEffect(() => {
63
- let active = true;
64
- void client.listInstruments(tenantSlug).then((cards) => {
65
- if (!active) return;
66
- setSavedCards(cards);
67
- const first = cards[0];
68
- if (first && !freshInstrument) setSelection(first.id);
69
- });
70
- return () => {
71
- active = false;
72
- };
73
- }, [tenantSlug, client, freshInstrument]);
74
-
75
- return { savedCards, selection, setSelection };
76
- }
77
-
78
- /**
79
- * The store's card public key for this order. The checkout config resolved the
80
- * initial key server-side (`GET /api/checkout/config`, FUT-697); the SPA
81
- * fetches through the order-scoped refresh route only when the ACTIVE provider
82
- * is PagBank — the sole provider that can mint a key on demand — and the
83
- * config arrived without one. A `null` key never means "mock": stub permission
84
- * travels separately as `mockTokenization`.
85
- */
86
- function useCardPublicKey(
87
- orderId: string,
88
- config: CardTokenizationConfig,
89
- ): {
90
- publicKey: string | null;
91
- setPublicKey: (key: string) => void;
92
- } {
93
- const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
94
- const client = useCheckoutClientApi();
95
- const refreshable =
96
- config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
97
-
98
- useEffect(() => {
99
- if (config.publicKey !== null || !refreshable) return undefined;
100
- let active = true;
101
- void client.refreshBrowserKey({ orderId }).then((result) => {
102
- if (active && result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
103
- });
104
- return () => {
105
- active = false;
106
- };
107
- }, [orderId, config.publicKey, refreshable, client]);
108
-
109
- return { publicKey, setPublicKey };
110
- }
111
37
 
112
38
  /** Everything the card view renders — all checkout state + the submit handler. */
113
39
  export interface CardCheckout {
@@ -137,6 +63,12 @@ export interface CardCheckout {
137
63
  pollTimedOut: boolean;
138
64
  /** Ask now and restart the wait — the buyer's "verificar de novo". */
139
65
  pollCheckAgain: () => void;
66
+ /**
67
+ * The order-scoped key refresh has not answered yet (`./card-sources.ts`): a
68
+ * charge started now would tokenize a new card with no key. A screen that
69
+ * starts the charge itself waits for this to clear.
70
+ */
71
+ keyPending: boolean;
140
72
  handlePay: () => Promise<void>;
141
73
  }
142
74
 
@@ -160,6 +92,7 @@ type CardSubmit = Pick<
160
92
  | "pollError"
161
93
  | "pollTimedOut"
162
94
  | "pollCheckAgain"
95
+ | "keyPending"
163
96
  | "handlePay"
164
97
  >;
165
98
 
@@ -246,7 +179,7 @@ function useCardWait(
246
179
  * poll for the async confirmation, bubbling the terminal status up via
247
180
  * {@link onResolved}.
248
181
  */
249
- function useCardSubmit(
182
+ export function useCardSubmit(
250
183
  order: CheckoutOrder,
251
184
  buyer: BuyerInfo,
252
185
  providerConfig: CardTokenizationConfig,
@@ -264,7 +197,7 @@ function useCardSubmit(
264
197
  const [errorCode, setErrorCode] = useState<string | null>(null);
265
198
  // The active key: resolved by the checkout config, overridden by the
266
199
  // rotated-key self-heal for the rest of the session.
267
- const { publicKey, setPublicKey } = useCardPublicKey(order.orderId, providerConfig);
200
+ const { publicKey, setPublicKey, keyPending } = useCardPublicKey(order.orderId, providerConfig);
268
201
  const client = useCheckoutClientApi();
269
202
  const navigate = useCheckoutNavigate();
270
203
  const cardCopy = useCheckoutCopy().card;
@@ -323,7 +256,7 @@ function useCardSubmit(
323
256
  else setSubmitted(true);
324
257
  };
325
258
 
326
- return { submitting, submitted, error, errorCode, ...wait, handlePay };
259
+ return { submitting, submitted, error, errorCode, ...wait, keyPending, handlePay };
327
260
  }
328
261
 
329
262
  /**
@@ -5,6 +5,7 @@ import {
5
5
  resumeSurface,
6
6
  useCheckoutNav,
7
7
  useCreateFailure,
8
+ useDropOrder,
8
9
  useGoToPayment,
9
10
  useResumedCheckout,
10
11
  useRetryAction,
@@ -135,6 +136,7 @@ export function useCheckoutController(
135
136
  return next;
136
137
  });
137
138
  }, [clearError, tenantSlug]);
139
+ const dropOrder = useDropOrder(setOrder, tenantSlug, clearError);
138
140
  const goToPayment = useGoToPayment({
139
141
  buyer, buyerFields, taxIdOnFile, saveProfile, saveBuyerContact, validation, failure, setStep,
140
142
  });
@@ -170,6 +172,6 @@ export function useCheckoutController(
170
172
  ...resumeSurface(resume, confirming),
171
173
  createError: failure.message, errorField: failure.field, errorCode: failure.code,
172
174
  goToMenu: onExitToMenu, back, editBuyer,
173
- goToPayment, startPayment, refresh, payWithEmail, handleResolved, retry, completed,
175
+ goToPayment, startPayment, refresh, payWithEmail, handleResolved, retry, completed, dropOrder,
174
176
  };
175
177
  }
@@ -68,6 +68,11 @@ function buildRuntime(config: PaymentFlowsConfig): FlowsRuntime {
68
68
  };
69
69
  }
70
70
 
71
+ /** The host's one-list Pagamento (FUT-3591), when it opted in — a hook, read every render. */
72
+ function useHostPaymentList(runtime: FlowsRuntime) {
73
+ return runtime.config.usePaymentList?.();
74
+ }
75
+
71
76
  /** The whole three-step flow, with the config fetched and availability decided. */
72
77
  function buildCheckout(
73
78
  runtime: FlowsRuntime,
@@ -80,6 +85,7 @@ function buildCheckout(
80
85
  const cart = runtime.config.useCart();
81
86
  const defaults = runtime.config.useBuyerDefaults?.() ?? {};
82
87
  const hostSettlement = runtime.config.useSettlement?.() ?? null;
88
+ const paymentList = useHostPaymentList(runtime);
83
89
  const { config, pending } = useResolvedConfig(runtime);
84
90
  const availability = runtime.useAvailability();
85
91
  const tenantSlug = runtime.useTenantSlug();
@@ -108,6 +114,7 @@ function buildCheckout(
108
114
  confirmationExtra={runtime.config.confirmation?.extra}
109
115
  confirmationHero={runtime.config.confirmation?.hero}
110
116
  validateApplePayMerchant={ports.validateApplePayMerchant}
117
+ paymentList={paymentList}
111
118
  />
112
119
  );
113
120
  }