@12-apps/payments-frontend 3.22.0 → 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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/checkout-actions.ts +55 -9
  3. package/src/components/checkout/checkout-flow.tsx +15 -7
  4. package/src/components/checkout/confirmation-wait.ts +97 -0
  5. package/src/components/checkout/en-US.ts +8 -0
  6. package/src/components/checkout/failure-codes.ts +82 -0
  7. package/src/components/checkout/hosted-store.ts +23 -1
  8. package/src/components/checkout/payment-error-panel.tsx +9 -3
  9. package/src/components/checkout/pix-view.tsx +97 -8
  10. package/src/components/checkout/poll-loop.ts +5 -3
  11. package/src/components/checkout/pt-BR.ts +9 -0
  12. package/src/components/checkout/types.ts +11 -0
  13. package/src/components/checkout/use-card-checkout.ts +3 -2
  14. package/src/components/checkout/use-checkout-controller.ts +19 -5
  15. package/src/components/checkout/use-payment-polling.ts +58 -7
  16. package/src/components/checkout/use-wallet-charge.ts +24 -1
  17. package/src/components/checkout/view-copy.ts +38 -0
  18. package/src/components/checkout/wallet-pane.tsx +6 -1
  19. package/src/flows/catalog-exit.ts +33 -0
  20. package/src/flows/create-payment-flows.tsx +12 -1
  21. package/src/flows/pipeline/actions.tsx +104 -0
  22. package/src/flows/pipeline/admission.ts +55 -0
  23. package/src/flows/pipeline/context.ts +140 -0
  24. package/src/flows/pipeline/derive-step.ts +234 -0
  25. package/src/flows/pipeline/engine-actions.ts +257 -0
  26. package/src/flows/pipeline/engine-chrome.tsx +123 -0
  27. package/src/flows/pipeline/engine-state.ts +107 -0
  28. package/src/flows/pipeline/engine.tsx +377 -0
  29. package/src/flows/pipeline/methods.ts +71 -0
  30. package/src/flows/pipeline/refusal-routing.ts +106 -0
  31. package/src/flows/pipeline/slices.ts +110 -0
  32. package/src/flows/pipeline/stable-plugins.ts +72 -0
  33. package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
  34. package/src/flows/pipeline/steps/index.ts +54 -0
  35. package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
  36. package/src/flows/pipeline/steps/status-step.tsx +41 -0
  37. package/src/flows/pipeline/types.ts +232 -0
  38. package/src/flows/public.ts +78 -0
  39. package/src/flows/screens-hosted.tsx +37 -4
  40. package/src/flows/screens-pay.tsx +6 -1
  41. package/src/flows/types.ts +25 -2
  42. package/src/index.ts +7 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.22.0",
3
+ "version": "3.23.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.",
@@ -22,7 +22,7 @@
22
22
  "storybook:build": "storybook build"
23
23
  },
24
24
  "dependencies": {
25
- "@12-apps/payments-backend": "^4.27.0",
25
+ "@12-apps/payments-backend": "^4.27.1",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
2
2
 
3
3
  import { buyerGateError } from "./buyer-gate";
4
+ import type { ConfirmationWait } from "./confirmation-wait";
4
5
  import type { CheckoutDecline } from "./decline";
5
6
  import { forgetHostedOrder, rememberHostedOrder } from "./hosted-return";
6
7
  import { parkedBasket, type CheckoutBasketIdentity } from "./basket";
@@ -65,23 +66,55 @@ export function useCheckoutNav(
65
66
  }
66
67
 
67
68
  /**
68
- * What the resumed leg contributes to the controller's surface.
69
+ * WHAT THE CONFIRMATION SCREEN KNOWS ABOUT ITS WAIT, from whichever wait is
70
+ * live.
69
71
  *
70
- * All of it is inert for a checkout that never left this tab and never raised
71
- * anything: with nothing parked the poll is disabled, so the error stays null,
72
- * the bound never elapses, and neither action has a wait to act on.
72
+ * Two can reach that screen and they are mutually exclusive by construction: a
73
+ * checkout RESUMED from a parked entry polls through `useHostedResume`, and one
74
+ * that never left this tab polls through the confirmation wait (FUT-1170) and
75
+ * that one stands down whenever a resumed order is what put the flow here. So
76
+ * the merge below is a choice between one live wait and one inert one, never a
77
+ * blend of two opinions.
78
+ *
79
+ * All of it is inert for a checkout with an answer already: with nothing to
80
+ * wait on both polls are disabled, so the error stays null, no bound elapses,
81
+ * and none of the actions has a wait to act on.
82
+ *
83
+ * `release` stays the resumed leg's alone. "Não consegui pagar" exists because
84
+ * a provider's own page produces no signal when a buyer abandons it (FUT-1146);
85
+ * a charge raised and still held on THIS page has no such gap to close.
73
86
  */
74
- export function resumeSurface(resume: HostedResume): {
87
+ export function resumeSurface(
88
+ resume: HostedResume,
89
+ confirming: ConfirmationWait,
90
+ ): {
91
+ awaitingTimedOut: boolean;
92
+ awaitingError: string | null;
93
+ awaitingCheckAgain: () => void;
94
+ /** @deprecated Renamed to `awaitingTimedOut`; kept so the rename ships additively. */
75
95
  resumeTimedOut: boolean;
96
+ /** @deprecated Renamed to `awaitingError`; kept so the rename ships additively. */
76
97
  resumeError: string | null;
98
+ /** @deprecated Renamed to `awaitingCheckAgain`; kept so the rename ships additively. */
77
99
  resumeCheckAgain: () => void;
78
100
  resumeRelease: (() => void) | undefined;
79
101
  resumeReleasing: boolean;
80
102
  } {
103
+ const live = resume.order === null ? confirming : resume;
81
104
  return {
82
- resumeTimedOut: resume.timedOut,
83
- resumeError: resume.error,
84
- resumeCheckAgain: resume.checkAgain,
105
+ awaitingTimedOut: live.timedOut,
106
+ awaitingError: live.error,
107
+ awaitingCheckAgain: live.checkAgain,
108
+ // The three old spellings, beside the new ones rather than replaced by them.
109
+ // This shape is PUBLIC — `CheckoutController` is `ReturnType<typeof
110
+ // useCheckoutController>` (flows/types.ts:148), re-exported from index.ts,
111
+ // and `PaymentFlows.useCheckout()` returns it. Dropping three members of it
112
+ // is a source break for any adopter that hand-composes the flow, and this
113
+ // release is a patch. The rename was cosmetic; breaking a published type for
114
+ // it is not a trade worth making, so the old names stay until a major.
115
+ resumeTimedOut: live.timedOut,
116
+ resumeError: live.error,
117
+ resumeCheckAgain: live.checkAgain,
85
118
  resumeRelease: resume.release,
86
119
  resumeReleasing: resume.releasing,
87
120
  };
@@ -316,6 +349,20 @@ export function useStartPayment(input: {
316
349
  async (chosen: PaymentMethod, override?: BuyerInfo) => {
317
350
  failure.clear();
318
351
  setDecline(null);
352
+ // THE CHARGE BEING REPLACED IS DROPPED FIRST (FUT-1170), before the raise
353
+ // rather than after it. Raising a payment means whatever was on screen is
354
+ // no longer the one being paid, and a provider round trip is long enough
355
+ // for the difference to matter: "Gerar novo código" left the expired
356
+ // charge mounted, so its own view polled it, got the terminal EXPIRED it
357
+ // was always going to get, and bounced the flow to the confirmation
358
+ // screen — where the new charge then landed with nothing polling it.
359
+ //
360
+ // A no-op on every other caller (the auto-raise, the alternate e-mail and
361
+ // the retry all run with no order held), which is the point: the clear
362
+ // belongs to what raising a payment MEANS, not to the one path that
363
+ // noticed.
364
+ setOrder(null);
365
+ setFinalStatus(null);
319
366
  setCreating(true);
320
367
  const result = await createOrder({ method: chosen, buyer: override ?? buyer, saveProfile });
321
368
  setCreating(false);
@@ -331,7 +378,6 @@ export function useStartPayment(input: {
331
378
  // the payment they just made.
332
379
  rememberHostedOrder(result.data, { tenantSlug, basket: parkedBasket(basket) });
333
380
  setOrder(result.data);
334
- setFinalStatus(null);
335
381
  },
336
382
  [
337
383
  buyer, saveProfile, createOrder, failure, navigate, tenantSlug, basket,
@@ -197,12 +197,14 @@ function StatusStep({
197
197
  onRegenerate={() => { c.setStep("payment"); void c.startPayment("PIX"); }}
198
198
  onBackToMenu={c.goToMenu}
199
199
  paidExtra={confirmationExtra}
200
- awaitingTimedOut={c.resumeTimedOut}
201
- // The resumed leg's own trouble, and the way out of it (FUT-1144). Both
202
- // are inert for a checkout that never left this tab nothing was parked,
203
- // so nothing is being polled here.
204
- awaitingError={c.resumeError}
205
- onCheckAgain={c.resumeCheckAgain}
200
+ awaitingTimedOut={c.awaitingTimedOut}
201
+ // How the wait behind this screen is going, and the way out of it
202
+ // (FUT-1144). It is the resumed leg's wait for a checkout that came back
203
+ // from a provider's page, and this step's own (FUT-1170) for one that
204
+ // never left the tab — which used to have no wait at all, and so a
205
+ // spinner that stood for nothing.
206
+ awaitingError={c.awaitingError}
207
+ onCheckAgain={c.awaitingCheckAgain}
206
208
  // The buyer's own way out of a hosted wait with no terminal state
207
209
  // (FUT-1146). Absent — and so unrendered — until the resumed leg has one
208
210
  // to offer, which is every checkout that never left this tab.
@@ -229,6 +231,12 @@ function StatusStep({
229
231
  * A cart still being FETCHED is empty in exactly the way a real one is not, so
230
232
  * a host that wires `identity` gets this screen when its cart is empty rather
231
233
  * than when it is late — which is also the moment the resume decision waits for.
234
+ *
235
+ * A RAISE IN FLIGHT is not "nothing to pay for" either (FUT-1170). Raising a
236
+ * charge now drops the one it replaces before it asks for the new one, so there
237
+ * is a window with no order held — and a checkout whose cart the host had
238
+ * already emptied would otherwise swap the buyer onto the empty-cart screen
239
+ * mid-regenerate, discarding the charge as it arrived.
232
240
  */
233
241
  function nothingToPayFor(
234
242
  settlement: SettlementCheckout | null | undefined,
@@ -236,7 +244,7 @@ function nothingToPayFor(
236
244
  c: ReturnType<typeof useCheckoutController>,
237
245
  ): boolean {
238
246
  if (settlement || !cart.empty || !cartLoaded(cart)) return false;
239
- return !c.order && c.step !== "status";
247
+ return !c.order && !c.creating && c.step !== "status";
240
248
  }
241
249
 
242
250
  /** Step 2, with the controller's facts and the host's money mapped onto it. */
@@ -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
+ }
@@ -134,4 +134,12 @@ export const EN_US_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
134
134
  action: "See the menu",
135
135
  },
136
136
  status: EN_US_PAYMENT_STATUS_COPY,
137
+ pipeline: {
138
+ loading: "Loading…",
139
+ // The KEYS are the package's own settlement-method ids, never words.
140
+ awaitingHandover: {
141
+ PIX: "Opening Pix…",
142
+ CARD: "Opening the card payment…",
143
+ },
144
+ },
137
145
  };
@@ -15,3 +15,85 @@
15
15
  * one and must not word it as a failure.
16
16
  */
17
17
  export const UNRESOLVED_CODE = "PAYMENT_UNRESOLVED";
18
+
19
+ /**
20
+ * REFUSALS RE-SENDING THE SAME REQUEST CANNOT CLEAR (FUT-1182).
21
+ *
22
+ * A different exception from {@link UNRESOLVED_CODE}, and the difference is
23
+ * worth keeping: an unresolved charge withholds the retry because pressing it
24
+ * could take the buyer's money twice. These withhold it because pressing it
25
+ * cannot do anything at all. The order was refused for a fact about the world —
26
+ * the shop is shut, the mode is off, the booked slot is gone, the basket has
27
+ * already been paid for, the merchant has connected no provider — and the
28
+ * identical POST meets the identical fact. So the button was the screen's most
29
+ * prominent control and it was guaranteed to fail, phrased as though the buyer
30
+ * had got something wrong.
31
+ *
32
+ * ## Why a package names a host's vocabulary here
33
+ *
34
+ * Because the alternative is worse in both directions. A prop the host fills
35
+ * would leave every host that has not filled it with the defect this exists to
36
+ * remove; and a rule inferred from the STATUS code cannot separate these from
37
+ * the 400s and 409s that a retry genuinely clears (`CHARGE_MISMATCH` is a 409
38
+ * and re-raising is exactly what fixes it).
39
+ *
40
+ * What makes it safe is that the list is CLOSED and its members are wire
41
+ * constants: this file already names `PAYMENT_UNRESOLVED` for the same reason,
42
+ * and the transport beside it already speaks the routes those codes arrive on.
43
+ * A host that answers none of these loses nothing — an unknown code keeps the
44
+ * retry, which is the pre-1182 behaviour for every code.
45
+ *
46
+ * ## It never SUPPRESSES the message, only the button
47
+ *
48
+ * The host half of this ticket (FUT-1166) acts on the same codes: it refetches
49
+ * whatever was stale, so the screen behind the refusal corrects itself into the
50
+ * gate that says what the buyer can actually do. The refusal's own sentence has
51
+ * to stay on screen while that happens, or a shopper watches a checkout
52
+ * rearrange itself for no stated reason.
53
+ *
54
+ * ## What is deliberately NOT here
55
+ *
56
+ * `BASKET_NOT_FOUND` and `MODE_CATALOG_SCOPE`, because this list is the
57
+ * GUARANTEED half. Both name states a fresh read can legitimately answer
58
+ * differently, and withholding the retry on a maybe is how a buyer with a
59
+ * recoverable problem ends up with no control at all — the same asymmetry
60
+ * `CheckoutDecline.retriable` settles by treating silence as yes.
61
+ */
62
+ const NO_RETRY_CODES: readonly string[] = [
63
+ // The shop is shut, or shut by the time the slot came round.
64
+ "STORE_CLOSED",
65
+ "SCHEDULE_UNAVAILABLE",
66
+ // The store does not sell this way — a bookmarked URL, or a mode cookie that
67
+ // outlived the config allowing it.
68
+ "MODE_UNAVAILABLE",
69
+ // No provider connected. TWO spellings on purpose: the package's own routes
70
+ // answer `PAYMENT_NOT_CONFIGURED`, and a host that guards its order route
71
+ // before delegating answers its own. A client meets both.
72
+ "PAYMENT_NOT_CONFIGURED",
73
+ "PAYMENTS_NOT_CONFIGURED",
74
+ // Already bought — in another tab, or by whoever was settling alongside them.
75
+ // Retrying cannot un-pay it; the buyer wants their purchases, not this step.
76
+ "CART_ALREADY_PAID",
77
+ "BASKET_ALREADY_BOUGHT",
78
+ // Nothing left to charge for, and this step cannot put anything back.
79
+ "EMPTY_CART",
80
+ "COMANDA_CLOSED",
81
+ // The order cannot be delivered as asked. Fixed where the address is, which
82
+ // is not here.
83
+ "DELIVERY_UNAVAILABLE",
84
+ "DELIVERY_ADDRESS_REQUIRED",
85
+ ];
86
+
87
+ /**
88
+ * Whether the surface may offer to send this refused order again.
89
+ *
90
+ * SILENCE MEANS YES, and so does anything unrecognised: a refusal with no code,
91
+ * or one from a server this bundle is a release behind, keeps the retry. The
92
+ * two packages version independently and a host may answer a vocabulary of its
93
+ * own, so "never heard of it" has to degrade to the old behaviour rather than
94
+ * to a screen with nothing on it.
95
+ */
96
+ export function retryMayHelp(code: string | null | undefined): boolean {
97
+ if (code === undefined || code === null) return true;
98
+ return !NO_RETRY_CODES.includes(code);
99
+ }
@@ -259,7 +259,18 @@ export function isStale(parked: ParkedHostedOrder): boolean {
259
259
  * ask, FUT-1146's release, and the settle that ends a checkout normally), so
260
260
  * callers other than the read need a way to say "this one is finished".
261
261
  */
262
- export function forgetHostedOrder(): void {
262
+ export function forgetHostedOrder(tenantSlug?: string): void {
263
+ // SCOPED WHEN THE CALLER KNOWS WHOSE CHECKOUT IT IS (FUT-1240). One tab
264
+ // holds one slot for every store on a multi-tenant storefront, so an
265
+ // unscoped clear is a clear of whichever store happens to be parked — the
266
+ // mirror image of the unscoped RESUME the slug closed, and just as quiet: a
267
+ // shopper who switched stores mid-hand-off loses the confirmation for a
268
+ // payment they made, with nothing on screen to say why.
269
+ //
270
+ // The no-argument call is unchanged and still clears whatever is there. It
271
+ // is what a caller with no slug in hand means, and it is the read side's
272
+ // own rule: an entry nobody can attribute belongs to everybody.
273
+ if (tenantSlug !== undefined && !parkedBelongsTo(tenantSlug)) return;
263
274
  try {
264
275
  window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
265
276
  window.sessionStorage?.removeItem(LEGACY_KEY);
@@ -267,3 +278,14 @@ export function forgetHostedOrder(): void {
267
278
  // Storage disabled — there was nothing to clear.
268
279
  }
269
280
  }
281
+
282
+ /**
283
+ * Whether what is parked (if anything) is this store's.
284
+ *
285
+ * `true` with nothing parked, deliberately: there is no other store's entry to
286
+ * protect, and answering `false` would leave a stale key nobody may delete.
287
+ */
288
+ function parkedBelongsTo(tenantSlug: string): boolean {
289
+ const parked = readParked();
290
+ return parked === null || belongsHere(parked, tenantSlug);
291
+ }
@@ -2,14 +2,14 @@ import { Box } from "@mui/material";
2
2
  import { useState, type JSX } from "react";
3
3
 
4
4
  import { useCheckoutCopy } from "./copy-context";
5
- import { UNRESOLVED_CODE } from "./failure-codes";
5
+ import { retryMayHelp, UNRESOLVED_CODE } from "./failure-codes";
6
6
  import { useCheckoutComponents } from "./ui";
7
7
 
8
8
  /**
9
9
  * Order-creation failure shown inline on Pagamento — the buyer never leaves the
10
10
  * step. Split out of `checkout-steps.tsx` when it grew a second presentation.
11
11
  *
12
- * Three shapes, and which one renders is decided by the failure's CODE, never
12
+ * Four shapes, and which one renders is decided by the failure's CODE, never
13
13
  * by its prose:
14
14
  *
15
15
  * - the buyer e-mail was rejected (the owner testing with the store's own
@@ -19,6 +19,10 @@ import { useCheckoutComponents } from "./ui";
19
19
  * a new reference, outside the walk's re-probe of the old one: precisely
20
20
  * the double payment the message forbids, offered as the panel's most
21
21
  * prominent affordance;
22
+ * - the refusal is about the WORLD rather than the payment (FUT-1182) — the
23
+ * ordinary danger Alert, and no retry either. Re-sending the identical
24
+ * request meets the identical shut shop; see `retryMayHelp` for the list and
25
+ * for why the sentence stays while the button goes;
22
26
  * - anything else — the ordinary danger Alert with a retry.
23
27
  */
24
28
  export function PaymentErrorPanel({
@@ -48,7 +52,9 @@ export function PaymentErrorPanel({
48
52
  showIcon
49
53
  data-testid={unresolved ? "checkout-unresolved" : "checkout-error"}
50
54
  />
51
- {unresolved ? null : <RetryAffordance {...{ emailFlagged, onUseEmail, onRetry }} />}
55
+ {!unresolved && retryMayHelp(code) ? (
56
+ <RetryAffordance {...{ emailFlagged, onUseEmail, onRetry }} />
57
+ ) : null}
52
58
  </Box>
53
59
  );
54
60
  }
@@ -1,9 +1,10 @@
1
1
  import { Box } from "@mui/material";
2
- import { useEffect, useState, type JSX } from "react";
2
+ import { useEffect, useMemo, useState, type JSX } from "react";
3
3
  import QRCode from "react-qr-code";
4
4
 
5
5
  import { useCheckoutCopy } from "./copy-context";
6
6
  import { ContentCopyIcon } from "./icons";
7
+ import type { SettlingCopy } from "./screens-copy";
7
8
  import { StalledWait } from "./stalled-wait";
8
9
  import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
9
10
  import { useCheckoutComponents } from "./ui";
@@ -16,6 +17,54 @@ import { usePaymentPolling } from "./use-payment-polling";
16
17
  * the payment-status screen.
17
18
  */
18
19
 
20
+ /**
21
+ * How many polls at the opening cadence before the wait decays (FUT-1170).
22
+ *
23
+ * Twelve is thirty seconds at the default 2.5 s, which is the window the common
24
+ * case lives in: a buyer on this screen has the QR in front of them and pays
25
+ * within a few taps, and the answer lands within seconds of the webhook.
26
+ * Everything after that window is an abandoned tab, or a shopper who has gone
27
+ * to fetch a different phone — and paying full cadence for it taxes the case
28
+ * that matters to subsidise the one that does not.
29
+ */
30
+ const PIX_FAST_POLLS = 12;
31
+
32
+ /**
33
+ * The decayed cadence. Six asks a minute rather than twenty-four, and the two
34
+ * re-arm events (`visibilitychange`, `online`) still poll IMMEDIATELY when the
35
+ * buyer comes back from their bank app — which is the moment they are waiting
36
+ * on, so the slow phase costs them nothing there.
37
+ */
38
+ const PIX_SLOW_INTERVAL_MS = 15_000;
39
+
40
+ /**
41
+ * How long the wait outlives the code itself.
42
+ *
43
+ * The bound is the CHARGE's expiry, because that is the fact that ends this
44
+ * screen: after it, the server flips the order and answers a terminal EXPIRED.
45
+ * The grace is room for that flip to happen and be observed — stopping exactly
46
+ * at the expiry would end the wait one poll before the answer it exists for.
47
+ */
48
+ const PIX_EXPIRY_GRACE_MS = 60_000;
49
+
50
+ /**
51
+ * The bound for a charge whose expiry cannot be read — a malformed instant, or
52
+ * an order that arrived without one.
53
+ *
54
+ * Unbounded is not the safe reading. It was the shipped one, and it is what let
55
+ * a forgotten tab ask a provider every 2.5 s for as long as it stayed open. A
56
+ * quarter of an hour is longer than any PIX code this checkout raises, and the
57
+ * buyer gets the ask back with one press.
58
+ */
59
+ const PIX_FALLBACK_WAIT_MS = 15 * 60_000;
60
+
61
+ /** How long to keep asking about this charge — see the constants above. */
62
+ function pixWaitMs(expiresAt: string | undefined): number {
63
+ const deadline = expiresAt === undefined ? Number.NaN : Date.parse(expiresAt);
64
+ if (Number.isNaN(deadline)) return PIX_FALLBACK_WAIT_MS;
65
+ return Math.max(0, deadline - Date.now()) + PIX_EXPIRY_GRACE_MS;
66
+ }
67
+
19
68
  /** The copyable "copia e cola" strip with its copy button. */
20
69
  function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
21
70
  const { Button, Text } = useCheckoutComponents();
@@ -72,35 +121,66 @@ function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
72
121
  );
73
122
  }
74
123
 
124
+ /**
125
+ * Which of the wait's three faces the footer is showing — STOPPED, then STILL
126
+ * TRYING, then working.
127
+ *
128
+ * The card pane's order, and for its reason (`SubmittedState`): a wait that
129
+ * failed its way to its own clock carries both flags, and "we keep trying" over
130
+ * a wait nothing is scheduled for is the lie. The sentences are the shared
131
+ * {@link SettlingCopy} ones, so the two panes say the same thing about the same
132
+ * situation rather than drifting into two accounts of it.
133
+ */
134
+ function pixWaitPanel(
135
+ copy: SettlingCopy,
136
+ error: string | null,
137
+ timedOut: boolean,
138
+ ): { title: string; description: string; testId: string } | null {
139
+ if (timedOut) {
140
+ return { title: copy.takingLonger, description: copy.takingLongerHelp, testId: "pix-poll-timeout" };
141
+ }
142
+ if (error) return { title: copy.connectionLost, description: error, testId: "pix-poll-error" };
143
+ return null;
144
+ }
145
+
75
146
  /**
76
147
  * The live footer: the pulsing "awaiting payment" indicator, or — while the
77
- * poll cannot reach us — the same wait said out loud, with a way to hurry it.
148
+ * poll cannot reach us, or once it has stopped — the same wait said out loud,
149
+ * with a way to restart it.
78
150
  *
79
151
  * A WARNING rather than a danger (FUT-1144). The old red panel said "não foi
80
152
  * possível confirmar o pagamento" and meant it: four consecutive failures ended
81
153
  * the wait, so a shopper who paid during a ten-second blip watched a QR under a
82
154
  * final-sounding refusal that would never update. The QR is still good, the
83
155
  * wait is still running, and the sentence now says both.
156
+ *
157
+ * The elapsed face is FUT-1170's half. The PIX wait is bounded now, and a bound
158
+ * that stops the polling without changing what is on screen is the same silent
159
+ * failure one screen later: a pulsing dot beside "Aguardando pagamento…" for a
160
+ * wait that is no longer asking anything.
84
161
  */
85
162
  function PixPollFooter({
86
163
  error,
164
+ timedOut,
87
165
  onCheckAgain,
88
166
  }: {
89
167
  error: string | null;
168
+ timedOut: boolean;
90
169
  onCheckAgain: () => void;
91
170
  }): JSX.Element {
92
171
  const { Text } = useCheckoutComponents();
93
172
  const { pix, settling } = useCheckoutCopy().screens;
94
- if (error) {
173
+ const panel = pixWaitPanel(settling, error, timedOut);
174
+ if (panel) {
95
175
  // The same panel the card and wallet panes show, held to the width of the
96
176
  // copy-and-paste strip above it so the centred PIX column stays a column.
97
177
  return (
98
178
  <Box sx={{ width: "100%", maxWidth: 420 }}>
99
179
  <StalledWait
100
- title={settling.connectionLost}
101
- description={error}
180
+ title={panel.title}
181
+ description={panel.description}
102
182
  onCheckAgain={onCheckAgain}
103
- testId="pix-poll-error"
183
+ testId={panel.testId}
104
184
  actionTestId="pix-check-again"
105
185
  />
106
186
  </Box>
@@ -127,8 +207,17 @@ export function PixView({
127
207
  }): JSX.Element {
128
208
  const { Text } = useCheckoutComponents();
129
209
  const copy = useCheckoutCopy().screens.pix;
130
- const { status, error, checkAgain } = usePaymentPolling(order.orderId, {
210
+ // Fixed once per charge (FUT-1170): the bound is a span, so recomputing it
211
+ // every render would keep pushing the deadline out — and it is an effect
212
+ // dependency, so it would also restart the wait on every render.
213
+ const expiresAt = order.pix?.expiresAt;
214
+ const maxWaitMs = useMemo(() => pixWaitMs(expiresAt), [expiresAt]);
215
+ const { status, error, timedOut, checkAgain } = usePaymentPolling(order.orderId, {
131
216
  intervalMs: pollIntervalMs,
217
+ slowAfterPolls: PIX_FAST_POLLS,
218
+ // Never FASTER than the opening cadence: a host that opens slowly means it.
219
+ slowIntervalMs: Math.max(pollIntervalMs, PIX_SLOW_INTERVAL_MS),
220
+ maxWaitMs,
132
221
  });
133
222
 
134
223
  // Bubble a terminal status up once, so the parent can advance to the status step.
@@ -182,7 +271,7 @@ export function PixView({
182
271
  {copy.validUntil(validUntil)}
183
272
  </Text>
184
273
 
185
- <PixPollFooter error={error} onCheckAgain={checkAgain} />
274
+ <PixPollFooter error={error} timedOut={timedOut} onCheckAgain={checkAgain} />
186
275
  </Box>
187
276
  );
188
277
  }
@@ -32,8 +32,10 @@ export interface PollingOptions {
32
32
  /**
33
33
  * WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
34
34
  * `timedOut` once this many milliseconds have passed since the wait began.
35
- * Undefined ⇒ unbounded (the PIX consumer passes none its charge expires
36
- * server-side and comes back as a terminal EXPIRED).
35
+ * Undefined ⇒ unbounded, which no consumer here is any more. PIX passed none
36
+ * — its charge expires server-side and comes back terminal, true of a tab
37
+ * somebody is watching and not of one left open in a pocket, which asked a
38
+ * provider every 2.5 s for as long as it lived (FUT-1170).
37
39
  *
38
40
  * It used to be a count of HEALTHY polls, which measured the wrong thing in
39
41
  * the only case that matters. A wait that is failing makes no healthy polls,
@@ -126,7 +128,7 @@ function pollDelay(healthy: number, errors: number, options: PollingOptions): nu
126
128
  }
127
129
 
128
130
  /** Where a running wait writes what it has learned. */
129
- interface PollSink {
131
+ export interface PollSink {
130
132
  setStatus: (status: OrderStatus) => void;
131
133
  setError: (error: string | null) => void;
132
134
  setTimedOut: (timedOut: boolean) => void;
@@ -125,4 +125,13 @@ export const PT_BR_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
125
125
  action: "Ver cardápio",
126
126
  },
127
127
  status: PT_BR_PAYMENT_STATUS_COPY,
128
+ pipeline: {
129
+ loading: "Carregando…",
130
+ // Keyed by the settlement method's id. The package registers PIX and CARD;
131
+ // a host that registers another charged method adds its own line here.
132
+ awaitingHandover: {
133
+ PIX: "Abrindo o Pix…",
134
+ CARD: "Abrindo o pagamento com cartão…",
135
+ },
136
+ },
128
137
  };
@@ -185,6 +185,17 @@ export interface CreateOrderRequest {
185
185
  buyer: BuyerInfo;
186
186
  /** Opt-in: save buyer name/phone (not CPF — never persisted here) for next-checkout pre-fill. */
187
187
  saveProfile: boolean;
188
+ /**
189
+ * WHICH settlement the buyer chose, when it is not one of the two above
190
+ * (FUT-1240).
191
+ *
192
+ * `method` is what the CHAIN can be asked to charge, so a pipeline host's own
193
+ * registered settlement — "pay the courier", "pay the waiter" — cannot be
194
+ * said there, and without this every one of them arrives as the same request.
195
+ * ABSENT for `PIX` and `CARD`, which is what keeps it additive; otherwise the
196
+ * registered `SettlementMethodDescriptor.id` verbatim (`flows/pipeline`).
197
+ */
198
+ settlementMethod?: string;
188
199
  }
189
200
 
190
201
  /** What the flow hands the host's `saveBuyerContact` port on "Continuar". */