@12-apps/payments-frontend 3.37.0 → 3.39.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/card/types.ts +6 -0
  3. package/src/components/checkout/card-sources.ts +140 -0
  4. package/src/components/checkout/card-view.tsx +6 -2
  5. package/src/components/checkout/cart-changed.tsx +26 -15
  6. package/src/components/checkout/checkout-actions.ts +18 -0
  7. package/src/components/checkout/checkout-flow.tsx +24 -77
  8. package/src/components/checkout/google-pay-button.tsx +4 -4
  9. package/src/components/checkout/list/buyer-inline.tsx +125 -0
  10. package/src/components/checkout/list/charge-runners.tsx +175 -0
  11. package/src/components/checkout/list/choice-list.tsx +159 -0
  12. package/src/components/checkout/list/choice.ts +213 -0
  13. package/src/components/checkout/list/context.tsx +55 -0
  14. package/src/components/checkout/list/en-US.ts +63 -0
  15. package/src/components/checkout/list/list-bar.tsx +186 -0
  16. package/src/components/checkout/list/list-face.tsx +338 -0
  17. package/src/components/checkout/list/list-pix.tsx +206 -0
  18. package/src/components/checkout/list/online-content.tsx +144 -0
  19. package/src/components/checkout/list/option-row.tsx +125 -0
  20. package/src/components/checkout/list/payment-list-step.tsx +166 -0
  21. package/src/components/checkout/list/pt-BR.ts +79 -0
  22. package/src/components/checkout/list/types.ts +218 -0
  23. package/src/components/checkout/list/use-capability.ts +58 -0
  24. package/src/components/checkout/list/use-list-charge.ts +186 -0
  25. package/src/components/checkout/list/use-list-state.ts +152 -0
  26. package/src/components/checkout/payment-phase.tsx +119 -0
  27. package/src/components/checkout/pix-parts.tsx +3 -0
  28. package/src/components/checkout/pix-view.tsx +53 -20
  29. package/src/components/checkout/transport.ts +3 -1
  30. package/src/components/checkout/types.ts +9 -9
  31. package/src/components/checkout/use-card-checkout.ts +11 -78
  32. package/src/components/checkout/use-checkout-controller.ts +3 -1
  33. package/src/flows/create-payment-flows.tsx +7 -0
  34. package/src/flows/types.ts +7 -0
  35. package/src/index.ts +4 -5
  36. package/src/locales.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.37.0",
3
+ "version": "3.39.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.",
package/src/card/types.ts CHANGED
@@ -40,6 +40,12 @@ export interface SavedCard {
40
40
  expMonth: number;
41
41
  expYear: number;
42
42
  holder: string;
43
+ /**
44
+ * When the buyer last paid with it (ISO-8601), or `null`/absent when the
45
+ * host does not say. The one list preselects the card used LAST (FUT-3591);
46
+ * every other screen keeps the host's own order.
47
+ */
48
+ lastUsedAt?: string | null;
43
49
  }
44
50
 
45
51
  /** Per-field validation messages; `undefined` means the field is fine. */
@@ -0,0 +1,140 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import { NEW_CARD, tokenizerFor, type CardTokenizationConfig, type SavedCard } from "../../card";
4
+
5
+ import { useCheckoutClientApi } from "./client-context";
6
+
7
+ /**
8
+ * Where the card path's two inputs come from: the buyer's saved cards and the
9
+ * store's card key. Split out of `./use-card-checkout.ts` at its size gate when
10
+ * the one-list Pagamento step (FUT-3591) needed the saved cards BEFORE any
11
+ * order exists — the list preselects one — and needed to know when the key is
12
+ * still on its way, because its charge runs the moment the 5-second bar fills
13
+ * rather than after the buyer has spent a minute typing.
14
+ */
15
+
16
+ /**
17
+ * A list answer as a list: a host whose card route answers `null` (or
18
+ * nothing) has no saved card, and must not take the checkout down with it.
19
+ */
20
+ function asList(cards: SavedCard[] | null | undefined): SavedCard[] {
21
+ return Array.isArray(cards) ? cards : [];
22
+ }
23
+
24
+ /**
25
+ * The buyer's saved cards at this store (FUT-697), loaded once per store, and
26
+ * whether the list has answered — "no saved card" and "not asked yet" are
27
+ * different facts to a screen that preselects one.
28
+ */
29
+ export function useSavedCardList(tenantSlug: string | undefined): { cards: SavedCard[]; loaded: boolean } {
30
+ const [state, setState] = useState<{ cards: SavedCard[]; loaded: boolean }>({ cards: [], loaded: false });
31
+ const client = useCheckoutClientApi();
32
+ useEffect(() => {
33
+ let active = true;
34
+ // A list that cannot be read is an empty one that HAS answered: the
35
+ // buyer types a card, rather than waiting on a list that never comes.
36
+ void client
37
+ .listInstruments(tenantSlug)
38
+ .catch((): SavedCard[] => [])
39
+ .then((cards) => {
40
+ if (active) setState({ cards: asList(cards), loaded: true });
41
+ });
42
+ return () => {
43
+ active = false;
44
+ };
45
+ }, [tenantSlug, client]);
46
+ return state;
47
+ }
48
+
49
+ /**
50
+ * Saved-card list + current selection — scoped to the current store (FUT-697),
51
+ * so only cards the store's active provider can actually charge are offered.
52
+ * The slug arrives as an argument (the host's routing owns it); absent, the
53
+ * list is unscoped, exactly as before FUT-697.
54
+ *
55
+ * `freshInstrument`: the buyer is here because a card was REFUSED (FUT-1145),
56
+ * so the saved card this would otherwise preselect is the one that just
57
+ * failed. The list is still offered — another saved card may well work — but
58
+ * nothing is chosen for them, which puts the form in front of a buyer whose
59
+ * only untried instrument is a new one.
60
+ */
61
+ export function useSavedCards(
62
+ tenantSlug: string | undefined,
63
+ freshInstrument: boolean,
64
+ ): {
65
+ savedCards: SavedCard[];
66
+ selection: string;
67
+ setSelection: (id: string) => void;
68
+ } {
69
+ // The cards and the selection land in ONE update, as they always did: a
70
+ // render holding the cards with nothing selected would flash the new-card
71
+ // form at a buyer whose saved card is about to be chosen for them.
72
+ const [state, setState] = useState<{ savedCards: SavedCard[]; selection: string }>({ savedCards: [], selection: NEW_CARD });
73
+ const client = useCheckoutClientApi();
74
+ useEffect(() => {
75
+ let active = true;
76
+ void client
77
+ .listInstruments(tenantSlug)
78
+ .catch((): SavedCard[] => [])
79
+ .then((answer) => {
80
+ if (!active) return;
81
+ const cards = asList(answer);
82
+ const first = cards[0];
83
+ setState((prev) => ({ savedCards: cards, selection: first && !freshInstrument ? first.id : prev.selection }));
84
+ });
85
+ return () => {
86
+ active = false;
87
+ };
88
+ }, [tenantSlug, client, freshInstrument]);
89
+ const setSelection = useCallback((id: string) => setState((prev) => ({ ...prev, selection: id })), []);
90
+ return { savedCards: state.savedCards, selection: state.selection, setSelection };
91
+ }
92
+
93
+ /**
94
+ * The store's card public key for this order. The checkout config resolved the
95
+ * initial key server-side (`GET /api/checkout/config`, FUT-697); the SPA
96
+ * fetches through the order-scoped refresh route only when the ACTIVE provider
97
+ * is PagBank — the sole provider that can mint a key on demand — and the
98
+ * config arrived without one. A `null` key never means "mock": stub permission
99
+ * travels separately as `mockTokenization`.
100
+ *
101
+ * `keyPending` is true while that refresh has not answered: a charge started
102
+ * before it lands would tokenize with no key at all.
103
+ */
104
+ export function useCardPublicKey(
105
+ orderId: string,
106
+ config: CardTokenizationConfig,
107
+ ): {
108
+ publicKey: string | null;
109
+ setPublicKey: (key: string) => void;
110
+ keyPending: boolean;
111
+ } {
112
+ const [publicKey, setPublicKey] = useState<string | null>(config.publicKey);
113
+ const [answered, setAnswered] = useState(false);
114
+ const client = useCheckoutClientApi();
115
+ const refreshable =
116
+ config.provider !== null && tokenizerFor(config.provider) === "pagbank-sdk";
117
+ const asks = config.publicKey === null && refreshable;
118
+
119
+ useEffect(() => {
120
+ if (!asks) return undefined;
121
+ let active = true;
122
+ void client
123
+ .refreshBrowserKey({ orderId })
124
+ .then((result) => {
125
+ if (!active) return;
126
+ setAnswered(true);
127
+ if (result.ok && result.data.publicKey) setPublicKey(result.data.publicKey);
128
+ })
129
+ // A refresh that failed has answered too: the charge goes on and the
130
+ // tokenizer says why, rather than the bar waiting forever.
131
+ .catch(() => {
132
+ if (active) setAnswered(true);
133
+ });
134
+ return () => {
135
+ active = false;
136
+ };
137
+ }, [orderId, asks, client]);
138
+
139
+ return { publicKey, setPublicKey, keyPending: asks && !answered };
140
+ }
@@ -45,7 +45,11 @@ function CardHeading(): JSX.Element | null {
45
45
  * ending; now the clock is, and a wait that failed its way to the clock carries
46
46
  * both. "We keep trying" over a wait nothing is scheduled for is the lie.
47
47
  */
48
- function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
48
+ export function SubmittedState({
49
+ card,
50
+ }: {
51
+ card: Pick<CardCheckout, "pollTimedOut" | "pollError" | "pollCheckAgain">;
52
+ }): JSX.Element {
49
53
  const { LoadingState } = useCheckoutComponents();
50
54
  const copy = useCheckoutCopy().screens.settling;
51
55
  if (card.pollTimedOut) {
@@ -88,7 +92,7 @@ function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
88
92
  * "Não foi possível pagar" above a body that says "não pague de novo" — pushes
89
93
  * the buyer toward exactly the second payment it forbids.
90
94
  */
91
- function ChargeFailure({
95
+ export function ChargeFailure({
92
96
  message,
93
97
  unresolved,
94
98
  }: {
@@ -185,6 +185,11 @@ export function PaymentRefreshProvider({
185
185
  * The card pane's half: a charge the host refused with
186
186
  * {@link CART_CHANGED_CODE} re-reads the order, once per refusal.
187
187
  */
188
+ /** The step's "Atualizar pagamento" for the order on screen, or `null` when there is nothing to refresh. */
189
+ export function usePaymentRefresh(): (() => void) | null {
190
+ return useContext(PaymentRefreshContext);
191
+ }
192
+
188
193
  export function useRefreshOnCartChanged(errorCode: string | null): void {
189
194
  const refresh = useContext(PaymentRefreshContext);
190
195
  // Held in a ref: the step hands down a fresh closure on every render, and
@@ -227,8 +232,11 @@ export function CartChangedBoundary({
227
232
  creating,
228
233
  onGenerate,
229
234
  refresh,
235
+ actionInBar = false,
230
236
  children,
231
237
  }: {
238
+ /** The step's bar carries "Atualizar pagamento" itself (the one list): the notice only says why. */
239
+ actionInBar?: boolean;
232
240
  order: CheckoutOrder | null;
233
241
  /** Absent for a settlement: it pays a balance, not the cart, so its basket is no signal. */
234
242
  basket: CheckoutBasketIdentity | undefined;
@@ -245,7 +253,7 @@ export function CartChangedBoundary({
245
253
  // dropping what the buyer typed and the refusal that explains the new total.
246
254
  return (
247
255
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
248
- {(changed || error) && run ? <CartChangedNotice onRefresh={run} pending={pending} error={error} /> : null}
256
+ {(changed || error) && run ? <CartChangedNotice onRefresh={actionInBar ? undefined : run} pending={pending} error={error} /> : null}
249
257
  {/* Stale while the warning stands, so the veil goes with it the moment the
250
258
  new order lands. A PIX refresh mints a new code, which takes the old
251
259
  one off the screen first, so a failed one leaves nothing to copy. */}
@@ -262,7 +270,8 @@ function CartChangedNotice({
262
270
  pending = false,
263
271
  error = null,
264
272
  }: {
265
- onRefresh: () => void;
273
+ /** Absent: the bar carries the control. */
274
+ onRefresh?: () => void;
266
275
  pending?: boolean;
267
276
  /** A refresh that failed, in the host's words: said, with the same control to try again. */
268
277
  error?: string | null;
@@ -276,19 +285,21 @@ function CartChangedNotice({
276
285
  ) : (
277
286
  <Alert variant="warning" title={copy.heading} description={copy.support} showIcon />
278
287
  )}
279
- <Box>
280
- <Button
281
- variant="solid"
282
- color="primary"
283
- size="md"
284
- onClick={onRefresh}
285
- loading={pending}
286
- disabled={pending}
287
- dataTestId="checkout-cart-changed-refresh"
288
- >
289
- {copy.refreshAction}
290
- </Button>
291
- </Box>
288
+ {onRefresh ? (
289
+ <Box>
290
+ <Button
291
+ variant="solid"
292
+ color="primary"
293
+ size="md"
294
+ onClick={onRefresh}
295
+ loading={pending}
296
+ disabled={pending}
297
+ dataTestId="checkout-cart-changed-refresh"
298
+ >
299
+ {copy.refreshAction}
300
+ </Button>
301
+ </Box>
302
+ ) : null}
292
303
  </Box>
293
304
  );
294
305
  }
@@ -278,3 +278,21 @@ export function useRetryAction(input: {
278
278
  clearError, decline, order, setDecline, setFinalStatus, setFreshInstrument, setOrder, setStep,
279
279
  ]);
280
280
  }
281
+
282
+ /**
283
+ * Leave the raised order without choosing another method (FUT-3592): the one
284
+ * list's "Trocar a forma de pagamento" goes back to the list from a Pix, and
285
+ * the order — and its parked entry, scoped to this store — go with it, as a
286
+ * method switch takes them.
287
+ */
288
+ export function useDropOrder(
289
+ setOrder: Dispatch<SetStateAction<CheckoutOrder | null>>,
290
+ tenantSlug: string | undefined,
291
+ clearError: () => void,
292
+ ): () => void {
293
+ return useCallback(() => {
294
+ setOrder(null);
295
+ forgetHostedOrder(tenantSlug);
296
+ clearError();
297
+ }, [setOrder, tenantSlug, clearError]);
298
+ }
@@ -2,18 +2,20 @@ import { useMemo, type JSX, type ReactNode } from "react";
2
2
 
3
3
  import type { CheckoutBasketIdentity } from "./basket";
4
4
  import { buyerFieldsFor } from "./buyer-fields";
5
- import { EmptyCart, PaymentStep } from "./checkout-steps";
5
+ import { EmptyCart } from "./checkout-steps";
6
6
  import { DadosStep } from "./dados-step";
7
7
  import { PaymentStatus } from "./payment-status";
8
8
  import type { BackActionEmphasis } from "./payment-status-parts";
9
9
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
10
10
  import { CheckoutCopyProvider } from "./copy-context";
11
- import { OneClickProvider, useOneClick } from "./one-click";
11
+ import { useOneClick } from "./one-click";
12
12
  import { CheckoutComponentsProvider, type CheckoutComponents } from "./ui";
13
13
  import type { CheckoutViewCopy } from "./view-copy";
14
14
  import { CheckoutFrame, FlowHeader } from "./checkout-frame";
15
15
  import type { CheckoutDeliverySummary, CheckoutSummaryLine } from "./payment-summary";
16
16
  import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
17
+ import type { CheckoutPaymentList } from "./list/types";
18
+ import { PaymentPhase, settlementTotalOverride } from "./payment-phase";
17
19
 
18
20
 
19
21
  /** What the flow reads off the host's cart — display facts, never money math. */
@@ -111,6 +113,16 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
111
113
  backActionEmphasis?: BackActionEmphasis;
112
114
  /** Design-system slots; unfilled slots render the raw-MUI defaults. */
113
115
  components?: Partial<CheckoutComponents>;
116
+ /**
117
+ * Opt-in: the Pagamento step as ONE list (FUT-3591) — the store's online
118
+ * options and the host's door options in one radio group, nothing raised on
119
+ * a pick, the bar's button the only way an order is made, and the card
120
+ * charged after a 5-second bar the buyer can cancel (FUT-3592). The buyer's
121
+ * missing CPF is asked inside the option instead of on a Dados step first.
122
+ * Absent: the method tabs, the order summary and raise-on-pick, unchanged.
123
+ * See `./list/types.ts`.
124
+ */
125
+ paymentList?: CheckoutPaymentList;
114
126
  }
115
127
 
116
128
  /**
@@ -123,13 +135,6 @@ function cartLoaded(cart: CheckoutCartView): boolean {
123
135
  return cart.identity?.ready !== false;
124
136
  }
125
137
 
126
- /** The pay-bar total override when settling a settlement (else the cart's own totals). */
127
- function settlementTotalOverride(
128
- settlement: SettlementCheckout | null | undefined,
129
- ): { label: string; items: number } | undefined {
130
- return settlement ? { label: settlement.totalLabel, items: settlement.totalItems } : undefined;
131
- }
132
-
133
138
  /**
134
139
  * The facts the confirmation screen shows beside the total (FUT-593): which
135
140
  * order this was, and where its receipt went. Its own function so the optional
@@ -244,67 +249,17 @@ function nothingToPayFor(
244
249
  return !c.order && !c.creating && c.step !== "status";
245
250
  }
246
251
 
247
- /** Step 2, with the controller's facts and the host's money mapped onto it. */
248
- function PagamentoStep({
249
- c,
250
- cart,
251
- settlement,
252
- providerConfig,
253
- tenantSlug,
254
- validateApplePayMerchant,
255
- }: {
256
- c: ReturnType<typeof useCheckoutController>;
257
- cart: CheckoutCartView;
258
- settlement: SettlementCheckout | null | undefined;
259
- providerConfig: CheckoutProviderConfig | null | undefined;
260
- tenantSlug: string | undefined;
261
- validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
262
- }): JSX.Element {
263
- return (
264
- <PaymentStep
265
- method={c.method}
266
- onMethodChange={c.setMethod}
267
- order={c.order}
268
- buyer={c.buyer}
269
- creating={c.creating}
270
- createError={c.createError}
271
- errorField={c.errorField}
272
- errorCode={c.errorCode}
273
- onGenerate={(chosen) => void c.startPayment(chosen)}
274
- refresh={c.refresh}
275
- onUseEmail={c.payWithEmail}
276
- // Set only for a skipped-Dados flow (the controller decides); the payer
277
- // block hides itself when it is absent.
278
- onEditBuyer={c.editBuyer}
279
- providerConfig={providerConfig}
280
- tenantSlug={tenantSlug}
281
- // The amount, on the step that asks for it (FUT-1179).
282
- cartTotals={cart}
283
- totalOverride={settlementTotalOverride(settlement)}
284
- discountLines={cart.discountLines}
285
- // The order around the payment, for the summary (FUT-3525).
286
- lines={cart.lines}
287
- delivery={cart.delivery}
288
- orderNote={cart.orderNote}
289
- // Retrying a refused card: the failed saved card is not chosen again (FUT-1145).
290
- freshInstrument={c.freshInstrument}
291
- // The card path parks an order of its own for a 3-D Secure challenge, so
292
- // it needs the same basket the flow was mounted for (FUT-1213).
293
- basket={cart.identity}
294
- validateApplePayMerchant={validateApplePayMerchant}
295
- onResolved={c.handleResolved}
296
- />
297
- );
298
- }
299
-
300
252
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
301
- const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationHero, confirmationFooter, backActionEmphasis, validateApplePayMerchant, oneClick = false, ...ports } = props;
253
+ const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, confirmationHero, confirmationFooter, backActionEmphasis, oneClick = false, paymentList } = props;
254
+ const ports = { createOrder: props.createOrder, saveBuyerContact: props.saveBuyerContact, onExitToMenu: props.onExitToMenu, onPaid: props.onPaid };
302
255
  // Resolved for NO method on purpose (FUT-595): the Dados step opens before
303
256
  // the picker, and the form is filled once — so it asks for the union of what
304
257
  // any chain member may need rather than re-opening after the choice. A chain
305
258
  // that declares nothing degrades to CPF-required, never to "ask nothing".
306
259
  const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
307
- const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug, cart.identity);
260
+ // The list asks a missing CPF inside the option (M11), so it opens on
261
+ // Pagamento as a buyer with one on file always has (FUT-465).
262
+ const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile || Boolean(paymentList), buyerFields, tenantSlug, cart.identity);
308
263
  const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
309
264
 
310
265
  // A settlement settlement pays already-sent kitchen items — the cart is
@@ -320,7 +275,10 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
320
275
 
321
276
  return (
322
277
  <CheckoutFrame payment={c.step === "payment"}>
323
- <FlowHeader copy={copy} step={c.step} completed={c.completed} onBack={c.back} />
278
+ {/* In the list, the host's own stepper is the chrome around Pagamento. */}
279
+ {paymentList && c.step === "payment" ? null : (
280
+ <FlowHeader copy={copy} step={c.step} completed={c.completed} onBack={c.back} />
281
+ )}
324
282
 
325
283
  {c.step === "dados" ? (
326
284
  <DadosStep
@@ -339,18 +297,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
339
297
  />
340
298
  ) : null}
341
299
 
342
- {c.step === "payment" ? (
343
- <OneClickProvider armed={armed}>
344
- <PagamentoStep
345
- c={c}
346
- cart={cart}
347
- settlement={settlement}
348
- providerConfig={providerConfig}
349
- tenantSlug={tenantSlug}
350
- validateApplePayMerchant={validateApplePayMerchant}
351
- />
352
- </OneClickProvider>
353
- ) : null}
300
+ {c.step === "payment" ? <PaymentPhase props={props} c={c} armed={armed} /> : null}
354
301
 
355
302
  {c.step === "status" ? (
356
303
  <StatusStep
@@ -109,9 +109,9 @@ function isReadyToPayRequest(): Record<string, unknown> {
109
109
  }
110
110
 
111
111
  /** Step 4's request: the same card method, now carrying gateway + price. */
112
- function paymentDataRequest(
112
+ export function paymentDataRequest(
113
113
  params: GooglePayGatewayParams,
114
- order: CheckoutOrder,
114
+ order: Pick<CheckoutOrder, "totalCents">,
115
115
  ): Record<string, unknown> {
116
116
  return {
117
117
  apiVersion: 2,
@@ -144,7 +144,7 @@ function paymentDataRequest(
144
144
  }
145
145
 
146
146
  /** The buyer closed the sheet — a choice, not a failure to report. */
147
- function sheetDismissed(error: unknown): boolean {
147
+ export function sheetDismissed(error: unknown): boolean {
148
148
  return (
149
149
  typeof error === "object" &&
150
150
  error !== null &&
@@ -157,7 +157,7 @@ function sheetDismissed(error: unknown): boolean {
157
157
  * the button exists at all. `api` is injectable for tests and harnesses;
158
158
  * `undefined` means "load pay.js".
159
159
  */
160
- function useGooglePayClient(
160
+ export function useGooglePayClient(
161
161
  api: GooglePayApi | null | undefined,
162
162
  environment: "TEST" | "PRODUCTION",
163
163
  ): GooglePaymentsClient | null {
@@ -0,0 +1,125 @@
1
+ import { Box } from "@mui/material";
2
+ import { useEffect, useRef, type JSX } from "react";
3
+
4
+ import { formatCpf, onlyDigits, validateCpf } from "../../../card";
5
+ import { fieldSatisfied } from "../buyer-fields";
6
+ import { useCheckoutCopy } from "../copy-context";
7
+ import type { BuyerInfo, CheckoutCustomerField } from "../types";
8
+ import { useCheckoutComponents } from "../ui";
9
+
10
+ import { usePaymentListMode } from "./context";
11
+ import type { PaymentListCopy } from "./types";
12
+
13
+ /**
14
+ * The buyer's own details the chosen option needs, asked INSIDE it and ABOVE
15
+ * the card fields (M11, the owner's "maybe.. we should ask cpf first"): only
16
+ * the ones the store's chain requires for this method and the buyer arrived
17
+ * without. A buyer with a CPF on file never sees the field.
18
+ *
19
+ * When the last of them is complete, `onComplete` fires once — the card
20
+ * option moves the focus to "Número do cartão" with it, so the buyer's next
21
+ * keystroke lands where the next thing to type is.
22
+ */
23
+
24
+ type Copy = PaymentListCopy["buyer"];
25
+
26
+ function nameOf(field: CheckoutCustomerField, copy: Copy): string {
27
+ const names: Record<CheckoutCustomerField["key"], string> = {
28
+ taxId: copy.cpf,
29
+ phone: copy.phone,
30
+ name: copy.name,
31
+ email: copy.email,
32
+ };
33
+ return names[field.key];
34
+ }
35
+
36
+ /** The sentence under a field the buyer has started and not finished. */
37
+ function complaintOf(field: CheckoutCustomerField, value: string, copy: Copy, cpfInvalid: (v: string) => string | undefined): string | undefined {
38
+ if (!value.trim() || fieldSatisfied(field, value)) return undefined;
39
+ if (field.key === "taxId") return onlyDigits(value).length < 11 ? copy.cpfIncomplete : cpfInvalid(value);
40
+ if (field.key === "phone") return copy.phoneIncomplete;
41
+ return undefined;
42
+ }
43
+
44
+ const PLACEHOLDERS: Partial<Record<CheckoutCustomerField["key"], string>> = {
45
+ taxId: "000.000.000-00",
46
+ phone: "(00) 00000-0000",
47
+ };
48
+
49
+ export function BuyerInline({
50
+ asked,
51
+ buyer,
52
+ onBuyerChange,
53
+ copy,
54
+ provider,
55
+ onComplete,
56
+ }: {
57
+ asked: readonly CheckoutCustomerField[];
58
+ buyer: BuyerInfo;
59
+ onBuyerChange: (buyer: BuyerInfo) => void;
60
+ copy: Copy;
61
+ /** The store's provider, named in the "why" — `null` when it published no name. */
62
+ provider: string | null;
63
+ onComplete?: () => void;
64
+ }): JSX.Element | null {
65
+ const { Checkbox, Input, Text } = useCheckoutComponents();
66
+ const consent = usePaymentListMode()?.consent;
67
+ const cardCopy = useCheckoutCopy().card;
68
+ const complete = asked.every((field) => fieldSatisfied(field, buyer[field.key]));
69
+ const was = useRef(complete);
70
+ const latest = useRef(onComplete);
71
+ latest.current = onComplete;
72
+ useEffect(() => {
73
+ if (complete && !was.current) latest.current?.();
74
+ was.current = complete;
75
+ }, [complete]);
76
+ if (asked.length === 0) return null;
77
+ return (
78
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
79
+ {asked.map((field) => {
80
+ const value = buyer[field.key] ?? "";
81
+ const complaint = complaintOf(field, value, copy, (v) => validateCpf(v, cardCopy.fields));
82
+ // The label sits ABOVE the field, whole — "CPF (o PagBank pede para
83
+ // pagar pelo app)" (FN:182) — and a native <label> around both names
84
+ // the input with it; a floating label would cut the reason short.
85
+ return (
86
+ <Box key={field.key} component="label" sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
87
+ <Text variant="body" size="sm" as="span">
88
+ {nameOf(field, copy)}{" "}
89
+ <Text variant="body" size="sm" color="secondary" as="span">
90
+ {copy.why(provider)}
91
+ </Text>
92
+ </Text>
93
+ <Input
94
+ type={field.key === "phone" ? "tel" : "text"}
95
+ inputMode={field.key === "taxId" ? "numeric" : undefined}
96
+ variant="outlined"
97
+ size="md"
98
+ fullWidth
99
+ required
100
+ placeholder={PLACEHOLDERS[field.key]}
101
+ value={value}
102
+ error={Boolean(complaint)}
103
+ helperText={complaint}
104
+ onChange={(event) =>
105
+ onBuyerChange({
106
+ ...buyer,
107
+ [field.key]: field.key === "taxId" ? formatCpf(event.target.value) : event.target.value,
108
+ })
109
+ }
110
+ data-testid={`list-buyer-${field.key}`}
111
+ />
112
+ </Box>
113
+ );
114
+ })}
115
+ {consent ? (
116
+ <Checkbox
117
+ label={consent.label}
118
+ checked={consent.checked}
119
+ onChange={(_event, checked) => consent.onChange(checked)}
120
+ data-testid="list-save-profile"
121
+ />
122
+ ) : null}
123
+ </Box>
124
+ );
125
+ }