@12-apps/payments-frontend 3.21.3 → 3.22.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 (33) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/poll-loop.ts +10 -12
  18. package/src/components/checkout/poll-rearm.ts +86 -0
  19. package/src/components/checkout/providers/types.ts +20 -3
  20. package/src/components/checkout/pt-BR.ts +33 -0
  21. package/src/components/checkout/screens-copy.ts +14 -0
  22. package/src/components/checkout/screens-en-US.ts +1 -0
  23. package/src/components/checkout/screens-pt-BR.ts +3 -0
  24. package/src/components/checkout/transport.ts +21 -1
  25. package/src/components/checkout/types.ts +24 -0
  26. package/src/components/checkout/use-card-checkout.ts +31 -31
  27. package/src/components/checkout/use-checkout-controller.ts +51 -271
  28. package/src/components/checkout/use-hosted-resume.ts +326 -0
  29. package/src/components/checkout/view-copy.ts +32 -0
  30. package/src/components/checkout/wallet-pane.tsx +3 -0
  31. package/src/flows/create-payment-flows.tsx +7 -0
  32. package/src/flows/screens-hosted.tsx +19 -2
  33. package/src/index.ts +22 -0
@@ -1,8 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
2
  import { useMemo, type JSX, type ReactNode } from "react";
3
3
 
4
+ import type { CheckoutBasketIdentity } from "./basket";
4
5
  import { buyerFieldsFor } from "./buyer-fields";
5
- import { DadosStep, EmptyCart, PaymentStep } from "./checkout-steps";
6
+ import { EmptyCart, PaymentStep } from "./checkout-steps";
7
+ import { DadosStep } from "./dados-step";
6
8
  import { ArrowBackIcon } from "./icons";
7
9
  import { PaymentStatus } from "./payment-status";
8
10
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
@@ -29,6 +31,18 @@ export interface CheckoutCartView {
29
31
  totalItems: number;
30
32
  /** Host-rendered discount itemization under the pay-bar total (FUT-246). */
31
33
  discountLines?: ReactNode;
34
+ /**
35
+ * WHICH basket this is, so a payment raised from another one cannot resume
36
+ * itself over it (FUT-1213).
37
+ *
38
+ * Optional, and absent means the pre-1213 behaviour — a parked payment
39
+ * resumes on whatever checkout mounts next, which is what shipped and what
40
+ * this ticket exists to bound. A host supplies it by calling
41
+ * `basketSignature(lines)` on its own cart and passing `ready: false` while
42
+ * that cart is still loading; see `./basket.ts` for why the identity is the
43
+ * LINES and not the cart's id.
44
+ */
45
+ identity?: CheckoutBasketIdentity;
32
46
  }
33
47
 
34
48
  /**
@@ -84,6 +98,16 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
84
98
  components?: Partial<CheckoutComponents>;
85
99
  }
86
100
 
101
+ /**
102
+ * Whether the host's cart has actually answered yet.
103
+ *
104
+ * `true` for every host that wires no identity, which is what it meant before
105
+ * there was one to wire.
106
+ */
107
+ function cartLoaded(cart: CheckoutCartView): boolean {
108
+ return cart.identity?.ready !== false;
109
+ }
110
+
87
111
  /** The pay-bar total override when settling a settlement (else the cart's own totals). */
88
112
  function settlementTotalOverride(
89
113
  settlement: SettlementCheckout | null | undefined,
@@ -179,6 +203,87 @@ function StatusStep({
179
203
  // so nothing is being polled here.
180
204
  awaitingError={c.resumeError}
181
205
  onCheckAgain={c.resumeCheckAgain}
206
+ // The buyer's own way out of a hosted wait with no terminal state
207
+ // (FUT-1146). Absent — and so unrendered — until the resumed leg has one
208
+ // to offer, which is every checkout that never left this tab.
209
+ onNotPaid={c.resumeRelease}
210
+ releasing={c.resumeReleasing}
211
+ // WHY a card was refused, when the server said (FUT-1145). Read on
212
+ // FAILED only, where it picks the sentence and decides whether a retry
213
+ // could work at all.
214
+ decline={c.decline}
215
+ />
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Nothing to check out — and the cart has ANSWERED, which is the half FUT-1213
221
+ * added.
222
+ *
223
+ * A settlement pays already-sent items, so the cart is legitimately empty
224
+ * there. The guard cannot key on the Dados step either: skipping it (FUT-465)
225
+ * makes Pagamento the first screen. And it holds until an order exists — once
226
+ * one does, its lines are snapshotted server-side and the cart no longer speaks
227
+ * for it.
228
+ *
229
+ * A cart still being FETCHED is empty in exactly the way a real one is not, so
230
+ * a host that wires `identity` gets this screen when its cart is empty rather
231
+ * than when it is late — which is also the moment the resume decision waits for.
232
+ */
233
+ function nothingToPayFor(
234
+ settlement: SettlementCheckout | null | undefined,
235
+ cart: CheckoutCartView,
236
+ c: ReturnType<typeof useCheckoutController>,
237
+ ): boolean {
238
+ if (settlement || !cart.empty || !cartLoaded(cart)) return false;
239
+ return !c.order && c.step !== "status";
240
+ }
241
+
242
+ /** Step 2, with the controller's facts and the host's money mapped onto it. */
243
+ function PagamentoStep({
244
+ c,
245
+ cart,
246
+ settlement,
247
+ providerConfig,
248
+ tenantSlug,
249
+ validateApplePayMerchant,
250
+ }: {
251
+ c: ReturnType<typeof useCheckoutController>;
252
+ cart: CheckoutCartView;
253
+ settlement: SettlementCheckout | null | undefined;
254
+ providerConfig: CheckoutProviderConfig | null | undefined;
255
+ tenantSlug: string | undefined;
256
+ validateApplePayMerchant: ((validationURL: string) => Promise<unknown>) | undefined;
257
+ }): JSX.Element {
258
+ return (
259
+ <PaymentStep
260
+ method={c.method}
261
+ onMethodChange={c.setMethod}
262
+ order={c.order}
263
+ buyer={c.buyer}
264
+ creating={c.creating}
265
+ createError={c.createError}
266
+ errorField={c.errorField}
267
+ errorCode={c.errorCode}
268
+ onGenerate={(chosen) => void c.startPayment(chosen)}
269
+ onUseEmail={c.payWithEmail}
270
+ // Set only for a skipped-Dados flow (the controller decides); the payer
271
+ // block hides itself when it is absent.
272
+ onEditBuyer={c.editBuyer}
273
+ providerConfig={providerConfig}
274
+ tenantSlug={tenantSlug}
275
+ // The amount, on the step that asks for it (FUT-1179).
276
+ cartTotals={cart}
277
+ totalOverride={settlementTotalOverride(settlement)}
278
+ discountLines={cart.discountLines}
279
+ // Retrying a refused card: the saved card that failed is not chosen for
280
+ // them again (FUT-1145).
281
+ freshInstrument={c.freshInstrument}
282
+ // The card path parks an order of its own for a 3-D Secure challenge, so
283
+ // it needs the same basket the flow was mounted for (FUT-1213).
284
+ basket={cart.identity}
285
+ validateApplePayMerchant={validateApplePayMerchant}
286
+ onResolved={c.handleResolved}
182
287
  />
183
288
  );
184
289
  }
@@ -190,7 +295,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
190
295
  // any chain member may need rather than re-opening after the choice. A chain
191
296
  // that declares nothing degrades to CPF-required, never to "ask nothing".
192
297
  const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
193
- const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
298
+ const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug, cart.identity);
194
299
  const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
195
300
 
196
301
  // A settlement settlement pays already-sent kitchen items — the cart is
@@ -200,7 +305,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
200
305
  // Pagamento the first screen, so an empty cart would otherwise reach the
201
306
  // method picker. It holds until an order exists — once one does, its lines are
202
307
  // snapshotted server-side and the cart no longer speaks for it.
203
- if (!settlement && cart.empty && !c.order && c.step !== "status") {
308
+ if (nothingToPayFor(settlement, cart, c)) {
204
309
  return <EmptyCart copy={copy.emptyCart} onBack={c.goToMenu} />;
205
310
  }
206
311
 
@@ -229,24 +334,13 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
229
334
 
230
335
  {c.step === "payment" ? (
231
336
  <OneClickProvider armed={armed}>
232
- <PaymentStep
233
- method={c.method}
234
- onMethodChange={c.setMethod}
235
- order={c.order}
236
- buyer={c.buyer}
237
- creating={c.creating}
238
- createError={c.createError}
239
- errorField={c.errorField}
240
- errorCode={c.errorCode}
241
- onGenerate={(chosen) => void c.startPayment(chosen)}
242
- onUseEmail={c.payWithEmail}
243
- // Set only for a skipped-Dados flow (the controller decides); the
244
- // payer block hides itself when it is absent.
245
- onEditBuyer={c.editBuyer}
337
+ <PagamentoStep
338
+ c={c}
339
+ cart={cart}
340
+ settlement={settlement}
246
341
  providerConfig={providerConfig}
247
342
  tenantSlug={tenantSlug}
248
343
  validateApplePayMerchant={validateApplePayMerchant}
249
- onResolved={c.handleResolved}
250
344
  />
251
345
  </OneClickProvider>
252
346
  ) : null}
@@ -1,9 +1,9 @@
1
1
  import { Box } from "@mui/material";
2
2
  import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
- import { BuyerInfoForm } from "./buyer-info-form";
4
+ import type { CheckoutBasketIdentity } from "./basket";
5
+ import { displayTotals, PayBarTotal } from "./checkout-totals";
5
6
  import { useCheckoutCopy } from "./copy-context";
6
- import { LockOutlinedIcon } from "./icons";
7
7
  import { useMethodChoice } from "./method-choice";
8
8
  import { MethodPicker } from "./method-picker";
9
9
  import { PaymentErrorPanel } from "./payment-error-panel";
@@ -12,13 +12,12 @@ import { resolveCheckoutScreen } from "./providers/registry";
12
12
  import type {
13
13
  BuyerField,
14
14
  BuyerInfo,
15
- CheckoutCustomerField,
16
15
  CheckoutOrder,
17
16
  CheckoutProviderConfig,
18
- OrderStatus,
17
+ OnCheckoutResolved,
19
18
  PaymentMethod,
20
19
  } from "./types";
21
- import type { DadosStepCopy, EmptyCartCopy } from "./view-copy";
20
+ import type { EmptyCartCopy } from "./view-copy";
22
21
  import { useCheckoutComponents } from "./ui";
23
22
 
24
23
  /**
@@ -70,6 +69,8 @@ function PaymentBody({
70
69
  onStart,
71
70
  creating,
72
71
  pollIntervalMs,
72
+ freshInstrument,
73
+ basket,
73
74
  validateApplePayMerchant,
74
75
  }: {
75
76
  order: CheckoutOrder | null;
@@ -77,11 +78,13 @@ function PaymentBody({
77
78
  providerConfig: CheckoutProviderConfig | null;
78
79
  method: PaymentMethod | null;
79
80
  tenantSlug?: string;
80
- onResolved: (status: OrderStatus) => void;
81
+ onResolved: OnCheckoutResolved;
81
82
  /** Set only when the shell hid its picker — see {@link PaymentStep}. */
82
83
  onStart?: () => void;
83
84
  creating: boolean;
84
85
  pollIntervalMs?: number;
86
+ freshInstrument?: boolean;
87
+ basket?: CheckoutBasketIdentity;
85
88
  validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
86
89
  }): JSX.Element | null {
87
90
  const Screen = resolveCheckoutScreen(providerConfig?.chain?.[0]?.checkoutScreen);
@@ -96,6 +99,8 @@ function PaymentBody({
96
99
  onStart={onStart}
97
100
  creating={creating}
98
101
  pollIntervalMs={pollIntervalMs}
102
+ freshInstrument={freshInstrument}
103
+ basket={basket}
99
104
  validateApplePayMerchant={validateApplePayMerchant}
100
105
  />
101
106
  );
@@ -116,162 +121,45 @@ export function EmptyCart({ copy, onBack }: { copy: EmptyCartCopy; onBack: () =>
116
121
  );
117
122
  }
118
123
 
119
- /**
120
- * The totals shown on the pay bar: the settled balance's when settling a settlement
121
- * one, otherwise the cart's own — both supplied by the host, which
122
- * is the only side that knows either.
123
- */
124
- function displayTotals(
125
- override: { label: string; items: number } | undefined,
126
- cart: { totalLabel: string; totalItems: number },
127
- ): { label: string; items: number } {
128
- return { label: override?.label ?? cart.totalLabel, items: override?.items ?? cart.totalItems };
129
- }
130
-
131
- /** The pay bar's money column: item count, grand total, host discount lines. */
132
- function PayBarTotal({
133
- totalLabel,
134
- totalItems,
135
- children,
136
- }: {
137
- totalLabel: string;
138
- totalItems: number;
139
- children?: ReactNode;
140
- }): JSX.Element {
141
- const { Text } = useCheckoutComponents();
142
- return (
143
- <Box sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
144
- <Text variant="caption" size="xs" color="secondary" as="span">
145
- Total · {totalItems} {totalItems === 1 ? "item" : "itens"}
146
- </Text>
147
- <Text variant="heading" size="md" weight="bold" color="primary" as="span" data-testid="pay-bar-total">
148
- {totalLabel}
149
- </Text>
150
- {children}
151
- </Box>
152
- );
124
+ /** What the step knows about the amount: the charge's, the balance's, the cart's. */
125
+ interface StepMoney {
126
+ order: CheckoutOrder | null;
127
+ cartTotals?: { totalLabel: string; totalItems: number };
128
+ totalOverride?: { label: string; items: number };
129
+ discountLines?: ReactNode;
153
130
  }
154
131
 
155
132
  /**
156
- * Step 1 "Dados" — the buyer's register info (CPF plus optional name/email/
157
- * phone; contact pre-filled from the saved buyer profile). NO payment method
158
- * here (that's step 2); nav lives in the slim checkout header. "Continuar"
159
- * (sticky, with the live total) validates the CPF and advances to "Pagamento"
160
- * — no charge yet.
161
- */
162
- export function DadosStep({
163
- copy,
164
- buyer,
165
- onBuyerChange,
166
- saveProfile,
167
- onSaveProfileChange,
168
- createError,
169
- errorField,
170
- onContinue,
171
- cartTotals,
172
- buyerFields,
173
- discountLines,
174
- totalOverride,
175
- }: {
176
- /** The step's own sentences — the HOST's words (see `./view-copy`). */
177
- copy: DadosStepCopy;
178
- buyer: BuyerInfo;
179
- onBuyerChange: (buyer: BuyerInfo) => void;
180
- saveProfile: boolean;
181
- onSaveProfileChange: (value: boolean) => void;
182
- createError: string | null;
183
- errorField: BuyerField | null;
184
- onContinue: () => void;
185
- /** The host cart's own totals — what the pay bar shows in cart mode. */
186
- cartTotals: { totalLabel: string; totalItems: number };
187
- /** What the store's chain declares it needs (FUT-595); absent ⇒ CPF-required. */
188
- buyerFields?: readonly CheckoutCustomerField[];
189
- /**
190
- * The saving, itemized under the total the buyer is about to authorize
191
- * (FUT-246) — RENDERED BY THE HOST from its cart (the storefront passes its
192
- * cart footer's money block), never re-implemented here, so the two surfaces
193
- * can never word the same discount differently.
133
+ * The Pagamento step's own money line (FUT-1179).
134
+ *
135
+ * THE RAISED ORDER'S TOTAL WINS, exactly as the confirmation screen's does
136
+ * (`statusTotalLabel`). Once a charge exists, that charge's amount is what is
137
+ * being taken a repriced cart, or an order resumed from a parked entry, makes
138
+ * the cart's total a different number from the one about to leave the buyer's
139
+ * account. Showing the cart's number above a QR for the order's would be this
140
+ * ticket's own defect in a new place: an amount on screen that is not the
141
+ * amount being charged.
142
+ *
143
+ * The item COUNT stays the cart's — an order carries no line count, and the
144
+ * caption is a description of the basket rather than of the charge.
145
+ *
146
+ * Renders nothing at all when the host supplied no totals and no order — a step
147
+ * that would otherwise print "Total ·" with a blank beside it is worse than the
148
+ * silence this ticket is about. Every host mounting `CheckoutFlow` gets them;
149
+ * only a hand-composed step can be missing them.
194
150
  */
195
- discountLines?: ReactNode;
196
- /** Settling an open balance: totals come from the settlement, not the cart. */
197
- totalOverride?: { label: string; items: number };
198
- }): JSX.Element {
199
- const { Checkbox } = useCheckoutComponents();
200
- const { label: totalLabel, items: totalItems } = displayTotals(totalOverride, cartTotals);
201
-
151
+ function PaymentStepTotal({ money }: { money: StepMoney }): JSX.Element | null {
152
+ const { order, cartTotals, totalOverride, discountLines } = money;
153
+ if (!cartTotals && !totalOverride && !order) return null;
154
+ const { label, items } = displayTotals(totalOverride, cartTotals ?? { totalLabel: "", totalItems: 0 });
202
155
  return (
203
- <>
204
- <Box sx={{ display: "flex", flexDirection: "column", gap: 3, pb: { xs: createError ? 22 : 14, sm: createError ? 20 : 12 } }}>
205
- <BuyerInfoForm
206
- value={buyer}
207
- onChange={onBuyerChange}
208
- fields={buyerFields}
209
- fieldError={errorField && createError ? { field: errorField, message: createError } : null}
210
- />
211
- <Checkbox
212
- checked={saveProfile}
213
- onChange={(_event, checked) => onSaveProfileChange(checked)}
214
- label={copy.saveProfile}
215
- data-testid="buyer-save-profile"
216
- />
217
- </Box>
218
-
219
- <DadosPayBar
220
- copy={copy}
221
- totalLabel={totalLabel}
222
- totalItems={totalItems}
223
- createError={createError}
224
- onContinue={onContinue}
225
- >
156
+ <Box data-testid="payment-step-total" sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2 }}>
157
+ <PayBarTotal totalLabel={order?.totalLabel ?? label} totalItems={items}>
226
158
  {/* Suppressed while settling a balance: those totals come from the
227
159
  frozen ticket, not the cart. */}
228
160
  {totalOverride ? null : discountLines}
229
- </DadosPayBar>
230
- </>
231
- );
232
- }
233
-
234
- /** The sticky "Continuar" bar: the refusal, the money, and the one action. */
235
- function DadosPayBar({
236
- copy,
237
- totalLabel,
238
- totalItems,
239
- createError,
240
- onContinue,
241
- children,
242
- }: {
243
- copy: DadosStepCopy;
244
- totalLabel: string;
245
- totalItems: number;
246
- createError: string | null;
247
- onContinue: () => void;
248
- children?: ReactNode;
249
- }): JSX.Element {
250
- const { ActionBar, Alert, Button, Text } = useCheckoutComponents();
251
- return (
252
- <ActionBar dataTestId="checkout-pay-bar">
253
- <Box sx={{ width: "100%", display: "flex", flexDirection: "column", gap: 1.5 }}>
254
- {createError ? (
255
- <Alert variant="danger" title={copy.cannotContinueTitle} description={createError} showIcon data-testid="checkout-error" />
256
- ) : null}
257
- <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
258
- <PayBarTotal totalLabel={totalLabel} totalItems={totalItems}>{children}</PayBarTotal>
259
- <Box sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.5, minWidth: 0 }}>
260
- <Button variant="solid" color="primary" size="lg" fullWidth onClick={onContinue} dataTestId="checkout-continue">
261
- {copy.continueAction}
262
- </Button>
263
- {copy.secureNotice ? (
264
- <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, color: "text.secondary" }}>
265
- <LockOutlinedIcon sx={{ fontSize: 13 }} />
266
- <Text variant="caption" size="xs" color="secondary" as="span">
267
- {copy.secureNotice}
268
- </Text>
269
- </Box>
270
- ) : null}
271
- </Box>
272
- </Box>
273
- </Box>
274
- </ActionBar>
161
+ </PayBarTotal>
162
+ </Box>
275
163
  );
276
164
  }
277
165
 
@@ -305,10 +193,39 @@ interface PaymentStepProps {
305
193
  providerConfig?: CheckoutProviderConfig | null;
306
194
  /** Scopes the saved-card list to the store being paid (host routing owns it). */
307
195
  tenantSlug?: string;
196
+ /**
197
+ * WHAT THE BUYER IS ABOUT TO PAY (FUT-1179) — the host cart's own totals, or
198
+ * the settlement's where one is being settled.
199
+ *
200
+ * The Pagamento step showed no amount at all before a method was chosen, and
201
+ * a store that finishes on the provider's page sent a buyer with a CPF on
202
+ * file straight out to that provider with NO total ever having been on
203
+ * screen: the flow opens on Pagamento, the hand-off screen owns the only
204
+ * button, and the amount lived exclusively on the Dados step they skipped.
205
+ * Asking for money without showing the amount is the one thing a checkout
206
+ * may not do.
207
+ *
208
+ * Optional so a host composing this step by hand is not broken by the
209
+ * addition; absent, the step renders as it did.
210
+ */
211
+ cartTotals?: { totalLabel: string; totalItems: number };
212
+ /** Settling an open balance: totals come from the settlement, not the cart. */
213
+ totalOverride?: { label: string; items: number };
214
+ /** The host's own rendered discount itemization, under the total (FUT-246). */
215
+ discountLines?: ReactNode;
308
216
  pollIntervalMs?: number;
217
+ /** Retrying a refused card — preselect no saved instrument (FUT-1145). */
218
+ freshInstrument?: boolean;
219
+ /**
220
+ * WHICH basket this checkout is for (FUT-1213) — passed down because the
221
+ * card path can park an order too: a 3-D Secure challenge is a hand-off, and
222
+ * one parked without a basket resumes over any basket at any store, which is
223
+ * this ticket's own bug on a sibling path.
224
+ */
225
+ basket?: CheckoutBasketIdentity;
309
226
  /** The host's Apple Pay merchant-validation port (FUT-472) — see the screen contract. */
310
227
  validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
311
- onResolved: (status: OrderStatus) => void;
228
+ onResolved: OnCheckoutResolved;
312
229
  }
313
230
 
314
231
  /**
@@ -341,18 +258,28 @@ export function PaymentStep({
341
258
  onEditBuyer,
342
259
  providerConfig,
343
260
  tenantSlug,
261
+ cartTotals,
262
+ totalOverride,
263
+ discountLines,
344
264
  pollIntervalMs,
265
+ freshInstrument,
266
+ basket,
345
267
  validateApplePayMerchant,
346
268
  onResolved,
347
269
  }: PaymentStepProps): JSX.Element {
348
- const { LoadingState } = useCheckoutComponents();
349
- const screens = useCheckoutCopy().screens;
350
270
  const config = providerConfig ?? null;
351
271
  const choice = useMethodChoice(config, method, onMethodChange);
352
272
  useAutoRaiseOrder(order, method, creating, createError, onGenerate);
353
273
 
354
274
  return (
355
275
  <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
276
+ {/* THE AMOUNT, before anything asks for it (FUT-1179). At the top rather
277
+ than in a sticky bar of its own: this step's actions belong to the
278
+ pane below it — the PIX code, the card form's own pay bar, the
279
+ hand-off button — and a second bar would put two "pay" controls on
280
+ one screen. */}
281
+ <PaymentStepTotal money={{ order, cartTotals, totalOverride, discountLines }} />
282
+
356
283
  {/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
357
284
  <PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
358
285
 
@@ -375,26 +302,74 @@ export function PaymentStep({
375
302
  onStart={choice.onStart}
376
303
  creating={creating}
377
304
  pollIntervalMs={pollIntervalMs}
305
+ freshInstrument={freshInstrument}
306
+ basket={basket}
378
307
  validateApplePayMerchant={validateApplePayMerchant}
379
308
  />
380
309
 
381
- {/* The shell's own busy spinner, SUPPRESSED for a hand-off screen: that
382
- screen renders its own "Preparando o pagamento" while the charge is
383
- raised, and two stacked spinners saying the same thing is what the
384
- buyer actually saw. */}
385
- {!order && creating && !choice.atProvider ? (
386
- <LoadingState variant="spinner" size="md" message={screens.generatingPayment} dataTestId="payment-generating" />
387
- ) : null}
388
-
389
- {!order && method && createError ? (
390
- <PaymentErrorPanel
391
- message={createError}
392
- emailFlagged={errorField === "email"}
393
- code={errorCode}
394
- onUseEmail={onUseEmail}
395
- onRetry={() => onGenerate(method)}
396
- />
397
- ) : null}
310
+ <RaisingState
311
+ order={order}
312
+ method={method}
313
+ creating={creating && !choice.atProvider}
314
+ createError={createError}
315
+ errorField={errorField}
316
+ errorCode={errorCode}
317
+ onUseEmail={onUseEmail}
318
+ onGenerate={onGenerate}
319
+ />
398
320
  </Box>
399
321
  );
400
322
  }
323
+
324
+ /**
325
+ * What the shell says while the order is being raised, and if raising it
326
+ * failed.
327
+ *
328
+ * The spinner is SUPPRESSED for a hand-off screen — that screen renders its own
329
+ * "Preparando o pagamento" while the charge is raised, and two stacked spinners
330
+ * saying the same thing is what the buyer actually saw. The caller decides
331
+ * that; here `creating` is simply true or false.
332
+ */
333
+ function RaisingState({
334
+ order,
335
+ method,
336
+ creating,
337
+ createError,
338
+ errorField,
339
+ errorCode,
340
+ onUseEmail,
341
+ onGenerate,
342
+ }: {
343
+ order: CheckoutOrder | null;
344
+ method: PaymentMethod | null;
345
+ creating: boolean;
346
+ createError: string | null;
347
+ errorField: BuyerField | null;
348
+ errorCode?: string | null;
349
+ onUseEmail: (email: string) => void;
350
+ onGenerate: (method: PaymentMethod) => void;
351
+ }): JSX.Element | null {
352
+ const { LoadingState } = useCheckoutComponents();
353
+ const screens = useCheckoutCopy().screens;
354
+ if (order) return null;
355
+ if (creating) {
356
+ return (
357
+ <LoadingState
358
+ variant="spinner"
359
+ size="md"
360
+ message={screens.generatingPayment}
361
+ dataTestId="payment-generating"
362
+ />
363
+ );
364
+ }
365
+ if (!method || !createError) return null;
366
+ return (
367
+ <PaymentErrorPanel
368
+ message={createError}
369
+ emailFlagged={errorField === "email"}
370
+ code={errorCode}
371
+ onUseEmail={onUseEmail}
372
+ onRetry={() => onGenerate(method)}
373
+ />
374
+ );
375
+ }
@@ -0,0 +1,51 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX, ReactNode } from "react";
3
+
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { useCheckoutComponents } from "./ui";
6
+
7
+ /**
8
+ * THE MONEY LINE both steps show.
9
+ *
10
+ * Its own module because two steps now render it — the Dados bar always did,
11
+ * and the Pagamento step does since FUT-1179, which is the ticket about a
12
+ * checkout that asked for money without ever showing the amount. One
13
+ * implementation so the two can never word the same total differently.
14
+ */
15
+
16
+ /**
17
+ * The totals shown on the pay bar: the settled balance's when settling a settlement
18
+ * one, otherwise the cart's own — both supplied by the host, which
19
+ * is the only side that knows either.
20
+ */
21
+ export function displayTotals(
22
+ override: { label: string; items: number } | undefined,
23
+ cart: { totalLabel: string; totalItems: number },
24
+ ): { label: string; items: number } {
25
+ return { label: override?.label ?? cart.totalLabel, items: override?.items ?? cart.totalItems };
26
+ }
27
+
28
+ /** The pay bar's money column: item count, grand total, host discount lines. */
29
+ export function PayBarTotal({
30
+ totalLabel,
31
+ totalItems,
32
+ children,
33
+ }: {
34
+ totalLabel: string;
35
+ totalItems: number;
36
+ children?: ReactNode;
37
+ }): JSX.Element {
38
+ const { Text } = useCheckoutComponents();
39
+ const copy = useCheckoutCopy().screens;
40
+ return (
41
+ <Box sx={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
42
+ <Text variant="caption" size="xs" color="secondary" as="span">
43
+ {copy.totalCaption(totalItems)}
44
+ </Text>
45
+ <Text variant="heading" size="md" weight="bold" color="primary" as="span" data-testid="pay-bar-total">
46
+ {totalLabel}
47
+ </Text>
48
+ {children}
49
+ </Box>
50
+ );
51
+ }
@@ -47,6 +47,9 @@ function unboundClient(copy: CheckoutTransportCopy): CheckoutClient {
47
47
  // The vault pair (FUT-183) has no `client.ts` free function to bind — it is
48
48
  // newer than that module. Built lazily from the default transport instead,
49
49
  // which is the same wire: `/api/checkout`, ambient `fetch` resolved per call.
50
+ // Same lazy build as the vault pair below, and for the same reason: this
51
+ // row is newer than `client.ts`, so there is no free function to bind.
52
+ releaseCheckout: (input) => createCheckoutClient({ copy }).releaseCheckout(input),
50
53
  beginVault: () => createCheckoutClient({ copy }).beginVault(),
51
54
  completeVault: (input) => createCheckoutClient({ copy }).completeVault(input),
52
55
  refreshBrowserKey: (input) => refreshCardPublicKey(input, copy),