@12-apps/payments-frontend 3.19.0 → 3.20.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.19.0",
3
+ "version": "3.20.1",
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.26.0",
25
+ "@12-apps/payments-backend": "^4.26.1",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -10,45 +10,47 @@ import {
10
10
 
11
11
  import { useCheckoutCopy } from "./copy-context";
12
12
  import { UNRESOLVED_CODE } from "./failure-codes";
13
+ import { StalledWait } from "./stalled-wait";
13
14
  import type { CardChainLink } from "./method-capability";
14
15
  import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
15
16
  import { useCheckoutComponents } from "./ui";
16
- import { useCardCheckout } from "./use-card-checkout";
17
+ import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
17
18
 
18
19
  /**
19
- * Post-submit confirmation state, error > timeout > spinner (FUT-191): a poll
20
- * failure is a danger Alert, the healthy-poll cap elapsing is a warning (the
21
- * order stays AWAITING server-side and is recoverable by webhook/reconcile/
22
- * backfill), and otherwise the now bounded — confirmation spinner shows.
20
+ * Post-submit confirmation state: timeout > error > spinner.
21
+ *
22
+ * The elapsed wall-clock wait leads (the order stays AWAITING server-side and is
23
+ * recoverable by webhook/reconcile/backfill, which is what its copy says), then
24
+ * a poll that cannot reach us — a warning saying we are STILL TRYING, where
25
+ * FUT-1144 found a danger Alert over a wait that had actually given up — then
26
+ * the ordinary bounded spinner.
27
+ *
28
+ * That order inverted with the meaning of the two flags. An error used to BE the
29
+ * ending; now the clock is, and a wait that failed its way to the clock carries
30
+ * both. "We keep trying" over a wait nothing is scheduled for is the lie.
23
31
  */
24
- function SubmittedState({
25
- pollError,
26
- pollTimedOut,
27
- }: {
28
- pollError: string | null;
29
- pollTimedOut: boolean;
30
- }): JSX.Element {
31
- const { Alert, LoadingState } = useCheckoutComponents();
32
+ function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
33
+ const { LoadingState } = useCheckoutComponents();
32
34
  const copy = useCheckoutCopy().screens.settling;
33
- if (pollError) {
35
+ if (card.pollTimedOut) {
34
36
  return (
35
- <Alert
36
- variant="danger"
37
- title={copy.cannotConfirm}
38
- description={pollError}
39
- showIcon
40
- data-testid="card-poll-error"
37
+ <StalledWait
38
+ title={copy.takingLonger}
39
+ description={copy.takingLongerHelp}
40
+ onCheckAgain={card.pollCheckAgain}
41
+ testId="card-poll-timeout"
42
+ actionTestId="card-check-again"
41
43
  />
42
44
  );
43
45
  }
44
- if (pollTimedOut) {
46
+ if (card.pollError) {
45
47
  return (
46
- <Alert
47
- variant="warning"
48
- title={copy.takingLonger}
49
- description={copy.takingLongerHelp}
50
- showIcon
51
- data-testid="card-poll-timeout"
48
+ <StalledWait
49
+ title={copy.connectionLost}
50
+ description={card.pollError}
51
+ onCheckAgain={card.pollCheckAgain}
52
+ testId="card-poll-error"
53
+ actionTestId="card-check-again"
52
54
  />
53
55
  );
54
56
  }
@@ -139,7 +141,7 @@ export function CardView({
139
141
  const unresolved = cc.errorCode === UNRESOLVED_CODE;
140
142
 
141
143
  if (cc.submitted) {
142
- return <SubmittedState pollError={cc.pollError} pollTimedOut={cc.pollTimedOut} />;
144
+ return <SubmittedState card={cc} />;
143
145
  }
144
146
 
145
147
  return (
@@ -7,6 +7,7 @@ import { ArrowBackIcon } from "./icons";
7
7
  import { PaymentStatus } from "./payment-status";
8
8
  import type { BuyerInfo, CheckoutProviderConfig, SettlementCheckout } from "./types";
9
9
  import { CheckoutCopyProvider } from "./copy-context";
10
+ import { OneClickProvider, useOneClick } from "./one-click";
10
11
  import { CheckoutComponentsProvider, useCheckoutComponents, type CheckoutComponents } from "./ui";
11
12
  import type { CheckoutViewCopy } from "./view-copy";
12
13
  import { useCheckoutController, type CheckoutHostPorts } from "./use-checkout-controller";
@@ -59,6 +60,17 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
59
60
  providerConfig?: CheckoutProviderConfig | null;
60
61
  /** Scopes the saved-card list to the store being paid. */
61
62
  tenantSlug?: string;
63
+ /**
64
+ * The buyer pressed a BUY button rather than opening a checkout — pay with
65
+ * their saved card and land them on Confirmação, with no tap in between.
66
+ *
67
+ * A REQUEST, never an instruction: it is honoured only where it can be, and
68
+ * degrades to the ordinary flow everywhere else — a store that finishes on
69
+ * the provider's page, a buyer with no CPF on file, a buyer with no saved
70
+ * card. See `./one-click.tsx` for the whole decision and why every clause
71
+ * narrows toward standing down.
72
+ */
73
+ oneClick?: boolean;
62
74
  /**
63
75
  * The host's Apple Pay merchant-validation port (FUT-472): exchange the
64
76
  * session's `validationURL` for an Apple merchant session, SERVER-SIDE.
@@ -162,18 +174,24 @@ function StatusStep({
162
174
  onBackToMenu={c.goToMenu}
163
175
  paidExtra={confirmationExtra}
164
176
  awaitingTimedOut={c.resumeTimedOut}
177
+ // The resumed leg's own trouble, and the way out of it (FUT-1144). Both
178
+ // are inert for a checkout that never left this tab — nothing was parked,
179
+ // so nothing is being polled here.
180
+ awaitingError={c.resumeError}
181
+ onCheckAgain={c.resumeCheckAgain}
165
182
  />
166
183
  );
167
184
  }
168
185
 
169
186
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
170
- const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
187
+ const { copy, cart, defaultBuyer, settlement, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, oneClick = false, ...ports } = props;
171
188
  // Resolved for NO method on purpose (FUT-595): the Dados step opens before
172
189
  // the picker, and the form is filled once — so it asks for the union of what
173
190
  // any chain member may need rather than re-opening after the choice. A chain
174
191
  // that declares nothing degrades to CPF-required, never to "ask nothing".
175
192
  const buyerFields = useMemo(() => buyerFieldsFor(providerConfig?.chain, null), [providerConfig]);
176
193
  const c = useCheckoutController(ports, defaultBuyer, taxIdOnFile, buyerFields, tenantSlug);
194
+ const armed = useOneClick({ requested: oneClick, config: providerConfig, taxIdOnFile, step: c.step, method: c.method, setMethod: c.setMethod });
177
195
 
178
196
  // A settlement settlement pays already-sent kitchen items — the cart is
179
197
  // legitimately empty here, so the empty-cart guard only applies to cart mode.
@@ -210,25 +228,27 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
210
228
  ) : null}
211
229
 
212
230
  {c.step === "payment" ? (
213
- <PaymentStep
214
- method={c.method}
215
- onMethodChange={c.setMethod}
216
- order={c.order}
217
- buyer={c.buyer}
218
- creating={c.creating}
219
- createError={c.createError}
220
- errorField={c.errorField}
221
- errorCode={c.errorCode}
222
- onGenerate={(chosen) => void c.startPayment(chosen)}
223
- onUseEmail={c.payWithEmail}
224
- // Set only for a skipped-Dados flow (the controller decides); the
225
- // payer block hides itself when it is absent.
226
- onEditBuyer={c.editBuyer}
227
- providerConfig={providerConfig}
228
- tenantSlug={tenantSlug}
229
- validateApplePayMerchant={validateApplePayMerchant}
230
- onResolved={c.handleResolved}
231
- />
231
+ <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}
246
+ providerConfig={providerConfig}
247
+ tenantSlug={tenantSlug}
248
+ validateApplePayMerchant={validateApplePayMerchant}
249
+ onResolved={c.handleResolved}
250
+ />
251
+ </OneClickProvider>
232
252
  ) : null}
233
253
 
234
254
  {c.step === "status" ? (
@@ -40,8 +40,17 @@ export const EN_US_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
40
40
  "If you have already paid, the order is confirmed as soon as the provider tells us — " +
41
41
  "do not pay again. You can close this screen.",
42
42
  },
43
+ awaitingUnreachable: {
44
+ heading: "We cannot reach the payment right now",
45
+ // "do not pay again" leads the second sentence for the same reason it
46
+ // leads `awaitingTimedOut`: a second payment is the expensive mistake.
47
+ support:
48
+ "We are still trying. If you have already paid, do not pay again — " +
49
+ "the order is confirmed as soon as the provider tells us.",
50
+ },
43
51
  retryAction: "Try again",
44
52
  regenerateAction: "Generate a new code",
53
+ checkAgainAction: "Check again",
45
54
  backAction: "Back to the menu",
46
55
  amountLabel: "Amount paid",
47
56
  referenceLabel: "Order",
@@ -0,0 +1,179 @@
1
+ /**
2
+ * ONE-CLICK checkout — the buyer who already decided (FUT-1070).
3
+ *
4
+ * A storefront can offer a BUY button beside a product or a past order: press
5
+ * it and the shopper expects to have bought, not to be handed a form. The
6
+ * whole flow that follows already exists — Pagamento raises the order, the
7
+ * card path charges a saved instrument, Confirmação reports the outcome — and
8
+ * every step of it is a tap the buyer has already made by pressing that
9
+ * button. So one-click makes those taps, in order, and reaches the same
10
+ * terminal screen through the same code the ordinary flow uses.
11
+ *
12
+ * That reuse is the design, not an economy. A second charge path would be a
13
+ * second answer to "what does paying with a saved card do", and the two would
14
+ * eventually disagree about failover instruments, the unresolved-charge rule,
15
+ * or the poll cap — each of which is money.
16
+ *
17
+ * ## It arms, or it stands down. It never guesses.
18
+ *
19
+ * `armedFor` is the whole decision, and every clause narrows toward
20
+ * NOT arming, because the failure directions are not symmetric: standing down
21
+ * costs a buyer the taps they would have made anyway, and arming wrongly
22
+ * charges a card nobody chose.
23
+ *
24
+ * - **No request** — the host did not ask. This is every ordinary checkout.
25
+ * - **No CPF on file** — the buyer still has a Dados step to fill (FUT-465),
26
+ * so there is no tap to skip and the flow opens where it always did. In
27
+ * practice a buyer with a saved card has one; a buyer without one is a buyer
28
+ * we have never charged.
29
+ * - **No protocol yet** (`config === null`, still loading or a fetch blip) —
30
+ * the ordinary flow fails OPEN here and renders a picker the server may
31
+ * refuse, which costs a tap. Arming on the same guess would raise a charge.
32
+ * - **The choice is not ours to ask** — a store that finishes on the
33
+ * provider's own page has no card path in this browser at all, and the one
34
+ * thing one-click must never do is redirect a checkout the moment it
35
+ * renders. This is the InfinitePay shape, and it is why a store on a hosted
36
+ * provider degrades to the ordinary hand-off screen with nothing else
37
+ * changed.
38
+ * - **No card path** — the chain declares no CARD entry this browser can mint
39
+ * or charge for.
40
+ *
41
+ * The last condition cannot be answered here at all: **whether the buyer has a
42
+ * saved card**. That list is fetched by the card path itself, scoped to the
43
+ * store, and asking for it twice would be two answers to one question. So an
44
+ * armed flow selects the card tile and the card view does the rest —
45
+ * {@link useOneClickPay} pays only once a SAVED card is the selection, which
46
+ * is a state the picker can only reach after the list came back non-empty. A
47
+ * buyer with no saved card therefore lands on Pagamento with the picker and
48
+ * the card form, which is exactly the ordinary step 2.
49
+ */
50
+ import {
51
+ createContext,
52
+ useContext,
53
+ useEffect,
54
+ useRef,
55
+ type JSX,
56
+ type ReactNode,
57
+ } from "react";
58
+
59
+ import { cardPathAvailable } from "./method-capability";
60
+ import { methodChosenAtProvider } from "./providers/registry";
61
+ import type { CheckoutProviderConfig, PaymentMethod } from "./types";
62
+
63
+ /**
64
+ * Whether the flow above this subtree is running as one-click.
65
+ *
66
+ * CONTEXT rather than a prop, because the consumer is the card path — four
67
+ * layers down, behind the published `ProviderCheckoutScreen` contract that
68
+ * every provider screen implements. Threading a flag through it would widen a
69
+ * contract that three screens share for the benefit of one, and would oblige
70
+ * an out-of-tree screen to forward a prop it has no use for.
71
+ */
72
+ const OneClickContext = createContext(false);
73
+
74
+ /** Arm (or explicitly disarm) one-click for everything below. */
75
+ export function OneClickProvider({
76
+ armed,
77
+ children,
78
+ }: {
79
+ armed: boolean;
80
+ children: ReactNode;
81
+ }): JSX.Element {
82
+ return <OneClickContext.Provider value={armed}>{children}</OneClickContext.Provider>;
83
+ }
84
+
85
+ /** Whether this subtree is a one-click checkout. `false` outside a provider. */
86
+ export function useOneClickArmed(): boolean {
87
+ return useContext(OneClickContext);
88
+ }
89
+
90
+ /**
91
+ * Can this store honour a one-click request right now? See the module comment
92
+ * for why every clause narrows toward `false`.
93
+ *
94
+ * `step` is what keeps the answer honest over TIME rather than only at mount:
95
+ * a resumed hosted return opens on Confirmação, and a buyer who walked back to
96
+ * Dados is a buyer who took over. Neither is a checkout that should still be
97
+ * charging on its own.
98
+ */
99
+ function armedFor(input: OneClickFlow): boolean {
100
+ const { requested, config, taxIdOnFile, step } = input;
101
+ if (!requested || !taxIdOnFile || step !== "payment" || !config) return false;
102
+ if (methodChosenAtProvider(config.chain?.[0]?.checkoutScreen, config)) return false;
103
+ return cardPathAvailable(config);
104
+ }
105
+
106
+ /** What the flow knows that decides whether one-click may run, and how it starts. */
107
+ interface OneClickFlow {
108
+ /** The host asked for one-click on this checkout. */
109
+ requested: boolean;
110
+ /** The store's published protocol; `null` while it is still unknown. */
111
+ config: CheckoutProviderConfig | null | undefined;
112
+ /** The buyer's CPF is already saved, so there is no Dados step to fill. */
113
+ taxIdOnFile: boolean;
114
+ /** Which step the flow is showing — one-click only ever runs on Pagamento. */
115
+ step: string;
116
+ /** The method currently selected, or `null` before any choice. */
117
+ method: PaymentMethod | null;
118
+ /** Selecting a method — the same event a picker tile press is. */
119
+ setMethod: (method: PaymentMethod) => void;
120
+ }
121
+
122
+ /**
123
+ * Arm one-click for this render, and take the card tile for the buyer ONCE
124
+ * when it is armed.
125
+ *
126
+ * Selecting a method is what raises the order (`useAutoRaiseOrder`), so that
127
+ * one call starts everything downstream — and it is the same event a tile
128
+ * press is, which is why nothing else in the flow has to change.
129
+ *
130
+ * Once only, by ref. `setMethod` clears any order raised for a previous
131
+ * method, so a re-fire would discard a live charge and raise a second; and a
132
+ * buyer who switches to PIX after this ran must be allowed to stay there.
133
+ */
134
+ export function useOneClick(flow: OneClickFlow): boolean {
135
+ const armed = armedFor(flow);
136
+ const { method, setMethod } = flow;
137
+ const taken = useRef(false);
138
+ useEffect(() => {
139
+ if (!armed || taken.current || method !== null) return;
140
+ taken.current = true;
141
+ setMethod("CARD");
142
+ }, [armed, method, setMethod]);
143
+ return armed;
144
+ }
145
+
146
+ /**
147
+ * Press "Pagar" for the buyer, ONCE, when a saved card is what is selected.
148
+ *
149
+ * `ready` is the caller's whole precondition and is deliberately narrow: a
150
+ * SAVED card is the current selection and no charge is in flight, has landed,
151
+ * or has already failed. It can only become true after the instrument list
152
+ * came back with something, which is what makes "the buyer has no saved card"
153
+ * a silent stand-down rather than a branch.
154
+ *
155
+ * The submit is held in a ref because `handlePay` is rebuilt every render;
156
+ * depending on it directly would re-run this effect constantly and leave the
157
+ * once-only guard as the only thing between a shopper and a second charge.
158
+ * One guard for one job: the ref fires it, `fired` decides whether it may.
159
+ *
160
+ * A DECLINE is terminal for one-click, and that is the point of listing
161
+ * `error` in `ready`: the buyer's own "Pagar R$ …" comes back under the
162
+ * refusal, and retrying a declined card automatically is how a shopper gets
163
+ * three identical declines they never asked for.
164
+ */
165
+ export function useOneClickPay(input: {
166
+ armed: boolean;
167
+ ready: boolean;
168
+ pay: () => Promise<void>;
169
+ }): void {
170
+ const { armed, ready } = input;
171
+ const submit = useRef(input.pay);
172
+ submit.current = input.pay;
173
+ const fired = useRef(false);
174
+ useEffect(() => {
175
+ if (!armed || !ready || fired.current) return;
176
+ fired.current = true;
177
+ void submit.current();
178
+ }, [armed, ready]);
179
+ }
@@ -4,7 +4,7 @@ import type { JSX, ReactNode } from "react";
4
4
  import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
5
5
  import type { OrderStatus } from "./types";
6
6
  import { useCheckoutComponents } from "./ui";
7
- import type { PaymentStatusCopy } from "./view-copy";
7
+ import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
8
8
 
9
9
  /**
10
10
  * The last screen of checkout.
@@ -79,37 +79,65 @@ function orderReference(orderId: string): string {
79
79
  return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
80
80
  }
81
81
 
82
+ /** How the wait itself is going, when it has not resolved into an outcome. */
83
+ interface WaitState {
84
+ /** The bounded wall-clock wait elapsed — nothing further is scheduled. */
85
+ timedOut: boolean;
86
+ /** The last poll failed, and the wait is still running (FUT-1144). */
87
+ unreachable: boolean;
88
+ }
89
+
90
+ /**
91
+ * Which of AWAITING's three faces this is.
92
+ *
93
+ * STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
94
+ * A wait that ran its clock out while failing carries BOTH flags — the last
95
+ * poll's error is still the last thing that happened — and saying "we keep
96
+ * trying" over a wait nothing is scheduled for is precisely the lie this ticket
97
+ * exists to remove. The elapsed state is also the one carrying "não pague de
98
+ * novo", which is the sentence that matters most when we have stopped looking.
99
+ *
100
+ * Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
101
+ * not resolved, and calm-but-alert is the visual for that.
102
+ */
103
+ function awaitingFace(
104
+ copy: PaymentStatusCopy,
105
+ wait: WaitState,
106
+ ): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
107
+ if (wait.timedOut) {
108
+ return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
109
+ }
110
+ if (wait.unreachable) {
111
+ return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
112
+ }
113
+ return null;
114
+ }
115
+
82
116
  /** The headline block: icon, outcome, and one supporting line. */
83
117
  function OutcomeHero({
84
118
  copy,
85
119
  status,
86
- timedOut = false,
120
+ wait,
87
121
  }: {
88
122
  copy: PaymentStatusCopy;
89
123
  status: OrderStatus;
90
- timedOut?: boolean;
124
+ wait: WaitState;
91
125
  }): JSX.Element {
92
126
  const { Text } = useCheckoutComponents();
93
- const timedOutWait = timedOut && status === "AWAITING_PAYMENT";
94
- // The timed-out wait keeps AWAITING's neutral clock icon but WARNING's tone:
95
- // the order is not resolved, and calm-but-alert is the visual for that.
96
- const visual = timedOutWait
97
- ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
127
+ const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
128
+ const visual = face
129
+ ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
98
130
  : OUTCOME_VISUAL[status];
99
- const outcome = timedOutWait ? copy.awaitingTimedOut : copy[OUTCOME_COPY_KEY[status]];
131
+ const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
100
132
  return (
101
133
  <Box
102
134
  // `payment-paid` is load-bearing for the storefront journeys — it is how
103
- // they assert the buyer actually got there. The timed-out wait gets its
135
+ // they assert the buyer actually got there. Each unsettled wait gets its
104
136
  // OWN id rather than reusing `payment-awaiting_payment`: a test that
105
- // cannot tell "still asking" from "stopped asking" is a test that would
106
- // pass against the unbounded spinner this replaced.
137
+ // cannot tell "still asking" from "stopped asking" from "cannot reach the
138
+ // payment" is a test that would pass against the spinner this replaced.
107
139
  data-testid={
108
- timedOut && status === "AWAITING_PAYMENT"
109
- ? "payment-awaiting-timeout"
110
- : status === "PAID"
111
- ? "payment-paid"
112
- : `payment-${status.toLowerCase()}`
140
+ face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
113
141
  }
114
142
  sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
115
143
  >
@@ -176,23 +204,41 @@ function PaidFacts({
176
204
  );
177
205
  }
178
206
 
179
- /** The next-action row: retry / regenerate for failures, always back-to-menu. */
207
+ /** The next-action row: retry / regenerate / check-again, always back-to-menu. */
180
208
  function StatusActions({
181
209
  copy,
182
210
  status,
183
211
  onRetry,
184
212
  onRegenerate,
213
+ onCheckAgain,
185
214
  onBackToMenu,
186
215
  }: {
187
216
  copy: PaymentStatusCopy;
188
217
  status: OrderStatus;
189
218
  onRetry?: () => void;
190
219
  onRegenerate?: () => void;
220
+ /**
221
+ * Offered only while the wait is unsettled AND not visibly working — the
222
+ * caller decides that; here it is simply present or absent. A button under a
223
+ * healthy spinner would invite a tap that changes nothing.
224
+ */
225
+ onCheckAgain?: () => void;
191
226
  onBackToMenu: () => void;
192
227
  }): JSX.Element {
193
228
  const { Button } = useCheckoutComponents();
194
229
  return (
195
230
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
231
+ {onCheckAgain ? (
232
+ <Button
233
+ variant="solid"
234
+ color="primary"
235
+ size="lg"
236
+ onClick={onCheckAgain}
237
+ dataTestId="payment-check-again"
238
+ >
239
+ {copy.checkAgainAction}
240
+ </Button>
241
+ ) : null}
196
242
  {status === "FAILED" && onRetry ? (
197
243
  <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
198
244
  {copy.retryAction}
@@ -218,18 +264,8 @@ function StatusActions({
218
264
  );
219
265
  }
220
266
 
221
- export function PaymentStatus({
222
- copy,
223
- status,
224
- totalLabel,
225
- orderId,
226
- buyerEmail,
227
- onRetry,
228
- onRegenerate,
229
- onBackToMenu,
230
- paidExtra,
231
- awaitingTimedOut = false,
232
- }: {
267
+ /** What the last screen of checkout is handed. */
268
+ interface PaymentStatusProps {
233
269
  /** Every sentence and label this screen renders — the HOST's words. */
234
270
  copy: PaymentStatusCopy;
235
271
  status: OrderStatus | null;
@@ -249,14 +285,69 @@ export function PaymentStatus({
249
285
  */
250
286
  paidExtra?: ReactNode;
251
287
  /**
252
- * The wait has been given up on see {@link AWAITING_TIMED_OUT}. Only
253
- * meaningful while AWAITING_PAYMENT; every other status has already resolved,
254
- * so a stale flag cannot change what a settled screen says.
288
+ * The wait has been given up on. Only meaningful while AWAITING_PAYMENT;
289
+ * every other status has already resolved, so a stale flag cannot change what
290
+ * a settled screen says.
255
291
  */
256
292
  awaitingTimedOut?: boolean;
257
- }): JSX.Element {
293
+ /**
294
+ * The wait's last poll failed (FUT-1144). Same scope rule as
295
+ * {@link awaitingTimedOut}, and it YIELDS to it: a wait that failed its way to
296
+ * the wall clock has both, and the honest thing to say then is that we have
297
+ * stopped asking.
298
+ */
299
+ awaitingError?: string | null;
300
+ /**
301
+ * Ask now. Rendered ONLY while the automatic wait is not visibly working —
302
+ * unreachable, or elapsed — so the buyer always has something to press when
303
+ * the spinner cannot honestly stand for progress, and nothing extra to think
304
+ * about when it can.
305
+ */
306
+ onCheckAgain?: () => void;
307
+ }
308
+
309
+ /**
310
+ * The wait has stopped LOOKING like progress: it cannot reach us, or it has run
311
+ * its clock out. Either way the spinner would be a lie and the buyer is owed
312
+ * something to press. Scoped to AWAITING because every other status has already
313
+ * resolved, so a stale flag cannot change what a settled screen says.
314
+ */
315
+ function isStalled(status: OrderStatus, wait: WaitState): boolean {
316
+ if (status !== "AWAITING_PAYMENT") return false;
317
+ return wait.timedOut || wait.unreachable;
318
+ }
319
+
320
+ /**
321
+ * The check-again action, but only where it can honestly be offered — a button
322
+ * under a healthy spinner invites a tap that changes nothing.
323
+ */
324
+ function offeredCheckAgain(
325
+ stalled: boolean,
326
+ onCheckAgain: (() => void) | undefined,
327
+ ): (() => void) | undefined {
328
+ return stalled ? onCheckAgain : undefined;
329
+ }
330
+
331
+ export function PaymentStatus({
332
+ copy,
333
+ status,
334
+ totalLabel,
335
+ orderId,
336
+ buyerEmail,
337
+ onRetry,
338
+ onRegenerate,
339
+ onBackToMenu,
340
+ paidExtra,
341
+ awaitingTimedOut = false,
342
+ awaitingError = null,
343
+ onCheckAgain,
344
+ }: PaymentStatusProps): JSX.Element {
258
345
  const { LoadingState } = useCheckoutComponents();
259
346
  const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
347
+ const wait: WaitState = { timedOut: awaitingTimedOut, unreachable: awaitingError !== null };
348
+ const stalled = isStalled(effective, wait);
349
+ const paid = effective === "PAID";
350
+ const spinning = effective === "AWAITING_PAYMENT" && !stalled;
260
351
 
261
352
  return (
262
353
  <Box
@@ -265,15 +356,15 @@ export function PaymentStatus({
265
356
  data-timed-out={awaitingTimedOut ? "true" : undefined}
266
357
  sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
267
358
  >
268
- <OutcomeHero copy={copy} status={effective} timedOut={awaitingTimedOut} />
359
+ <OutcomeHero copy={copy} status={effective} wait={wait} />
269
360
 
270
- {effective === "PAID" ? (
361
+ {paid ? (
271
362
  <PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
272
363
  ) : null}
273
364
 
274
- {effective === "PAID" ? paidExtra : null}
365
+ {paid ? paidExtra : null}
275
366
 
276
- {effective === "AWAITING_PAYMENT" && !awaitingTimedOut ? (
367
+ {spinning ? (
277
368
  <LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
278
369
  ) : null}
279
370
 
@@ -282,6 +373,7 @@ export function PaymentStatus({
282
373
  status={effective}
283
374
  onRetry={onRetry}
284
375
  onRegenerate={onRegenerate}
376
+ onCheckAgain={offeredCheckAgain(stalled, onCheckAgain)}
285
377
  onBackToMenu={onBackToMenu}
286
378
  />
287
379
  </Box>