@12-apps/payments-frontend 3.21.4 → 3.23.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 (59) 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 +387 -0
  6. package/src/components/checkout/checkout-flow.tsx +126 -24
  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/confirmation-wait.ts +97 -0
  11. package/src/components/checkout/dados-step.tsx +141 -0
  12. package/src/components/checkout/decline.ts +48 -0
  13. package/src/components/checkout/en-US.ts +41 -0
  14. package/src/components/checkout/failure-codes.ts +82 -0
  15. package/src/components/checkout/hosted-return.ts +190 -206
  16. package/src/components/checkout/hosted-store.ts +291 -0
  17. package/src/components/checkout/payment-error-panel.tsx +9 -3
  18. package/src/components/checkout/payment-status-parts.tsx +311 -0
  19. package/src/components/checkout/payment-status.tsx +69 -264
  20. package/src/components/checkout/pix-view.tsx +97 -8
  21. package/src/components/checkout/poll-loop.ts +5 -3
  22. package/src/components/checkout/providers/types.ts +20 -3
  23. package/src/components/checkout/pt-BR.ts +42 -0
  24. package/src/components/checkout/screens-copy.ts +14 -0
  25. package/src/components/checkout/screens-en-US.ts +1 -0
  26. package/src/components/checkout/screens-pt-BR.ts +3 -0
  27. package/src/components/checkout/transport.ts +21 -1
  28. package/src/components/checkout/types.ts +35 -0
  29. package/src/components/checkout/use-card-checkout.ts +34 -33
  30. package/src/components/checkout/use-checkout-controller.ts +68 -274
  31. package/src/components/checkout/use-hosted-resume.ts +326 -0
  32. package/src/components/checkout/use-payment-polling.ts +58 -7
  33. package/src/components/checkout/use-wallet-charge.ts +24 -1
  34. package/src/components/checkout/view-copy.ts +70 -0
  35. package/src/components/checkout/wallet-pane.tsx +9 -1
  36. package/src/flows/catalog-exit.ts +33 -0
  37. package/src/flows/create-payment-flows.tsx +19 -1
  38. package/src/flows/pipeline/actions.tsx +104 -0
  39. package/src/flows/pipeline/admission.ts +55 -0
  40. package/src/flows/pipeline/context.ts +140 -0
  41. package/src/flows/pipeline/derive-step.ts +234 -0
  42. package/src/flows/pipeline/engine-actions.ts +257 -0
  43. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  44. package/src/flows/pipeline/engine-state.ts +107 -0
  45. package/src/flows/pipeline/engine.tsx +377 -0
  46. package/src/flows/pipeline/methods.ts +71 -0
  47. package/src/flows/pipeline/refusal-routing.ts +106 -0
  48. package/src/flows/pipeline/slices.ts +110 -0
  49. package/src/flows/pipeline/stable-plugins.ts +72 -0
  50. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  51. package/src/flows/pipeline/steps/index.ts +54 -0
  52. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  53. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  54. package/src/flows/pipeline/types.ts +232 -0
  55. package/src/flows/public.ts +78 -0
  56. package/src/flows/screens-hosted.tsx +55 -5
  57. package/src/flows/screens-pay.tsx +6 -1
  58. package/src/flows/types.ts +25 -2
  59. package/src/index.ts +29 -19
@@ -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),
@@ -0,0 +1,97 @@
1
+ import { useEffect } from "react";
2
+
3
+ import type { OrderStatus } from "./types";
4
+ import { usePaymentPolling } from "./use-payment-polling";
5
+
6
+ /**
7
+ * THE CONFIRMATION STEP'S OWN WAIT (FUT-1170).
8
+ *
9
+ * Polling used to live entirely in the method's screen — the PIX code's footer,
10
+ * the card pane's post-submit state — which is right for the payment step and
11
+ * leaves the LAST step with none. A flow parked on Confirmação holding an
12
+ * unsettled order therefore rendered "Confirmando seu pagamento" over a spinner
13
+ * that stood for nothing: no poll was scheduled, no error could appear, no
14
+ * clock could elapse, and the only control on screen was "Voltar ao cardápio".
15
+ *
16
+ * FUT-1170 reaches that state through the regenerate path, and dropping the
17
+ * replaced charge closes that particular door. This is the other half, and it
18
+ * is the half that holds for doors nobody has opened yet: any caller that lands
19
+ * on the confirmation screen with a live order now has something asking about
20
+ * it, and — when the clock runs out — something to press.
21
+ *
22
+ * ## Why here rather than hoisting the screens' polls into the flow
23
+ *
24
+ * The three waits are genuinely different. PIX is bounded by the CODE's own
25
+ * expiry and decays over minutes; the card wait is 90 s from the submit; the
26
+ * resumed hosted leg has its own release action attached to it. One hoisted
27
+ * poll would have to serve all three at one cadence and one bound, and the
28
+ * cadence is the thing each of them tunes. So the payment step keeps the wait
29
+ * that belongs to the pane the buyer is looking at, and this covers the step
30
+ * that has no pane of its own.
31
+ *
32
+ * The two can never run together: this one is gated on the confirmation step,
33
+ * and every screen that polls renders on the payment step.
34
+ */
35
+
36
+ /** How often the confirmation screen asks. The same cadence the panes open at. */
37
+ const CONFIRMATION_INTERVAL_MS = 2_500;
38
+
39
+ /**
40
+ * How long it asks for, in WALL TIME — the card pane's bound, for the same
41
+ * reason it has one: past this point the spinner is no longer a description of
42
+ * anything, and the honest screen says so and offers the buyer the ask.
43
+ *
44
+ * Nothing is lost when it elapses. The order stays AWAITING server-side and is
45
+ * still recoverable by webhook, reconciliation or backfill; what changes is
46
+ * only that the screen stops pretending to watch.
47
+ */
48
+ const CONFIRMATION_WAIT_MS = 90_000;
49
+
50
+ /** What the confirmation screen reads off a wait it owns. */
51
+ export interface ConfirmationWait {
52
+ /** The bound elapsed — nothing further is scheduled. */
53
+ timedOut: boolean;
54
+ /** The last ask failed, and the wait is still running (FUT-1144). */
55
+ error: string | null;
56
+ /** Ask now, and start the clock over. */
57
+ checkAgain: () => void;
58
+ }
59
+
60
+ /**
61
+ * Watch the order the flow is holding, while the confirmation screen is the one
62
+ * on display and nothing has settled it yet.
63
+ *
64
+ * @param active Whether this wait is the live one. FALSE for every checkout
65
+ * that has an answer already, and for every step that polls for itself — the
66
+ * caller decides, because only it knows which screen is up.
67
+ * @param onSettled A terminal status arrived. Called once per status, with the
68
+ * status only: whether it carries a refusal is the charge path's knowledge and
69
+ * a poll has none of it.
70
+ */
71
+ export function useConfirmationWait(input: {
72
+ orderId: string | null;
73
+ active: boolean;
74
+ onSettled: (status: OrderStatus) => void;
75
+ }): ConfirmationWait {
76
+ const { orderId, active, onSettled } = input;
77
+ const { status, error, timedOut, checkAgain } = usePaymentPolling(orderId, {
78
+ enabled: active,
79
+ intervalMs: CONFIRMATION_INTERVAL_MS,
80
+ maxWaitMs: CONFIRMATION_WAIT_MS,
81
+ });
82
+
83
+ useEffect(() => {
84
+ if (!active || !status || status === "AWAITING_PAYMENT") return;
85
+ onSettled(status);
86
+ }, [active, status, onSettled]);
87
+
88
+ // Reported only while this wait is the live one. A bound that elapsed for an
89
+ // order the flow has since settled must not turn a paid confirmation into a
90
+ // warning, and `usePaymentPolling` keeps its last answer after `enabled` goes
91
+ // false — deliberately, so a settled wait can still be read.
92
+ return {
93
+ timedOut: active && timedOut,
94
+ error: active ? error : null,
95
+ checkAgain,
96
+ };
97
+ }