@12-apps/payments-frontend 3.21.4 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +2 -2
  2. package/src/components/checkout/basket.ts +85 -0
  3. package/src/components/checkout/card-outcome.ts +81 -0
  4. package/src/components/checkout/card-view.tsx +62 -22
  5. package/src/components/checkout/checkout-actions.ts +341 -0
  6. package/src/components/checkout/checkout-flow.tsx +112 -18
  7. package/src/components/checkout/checkout-steps.tsx +149 -174
  8. package/src/components/checkout/checkout-totals.tsx +51 -0
  9. package/src/components/checkout/client-context.tsx +3 -0
  10. package/src/components/checkout/dados-step.tsx +141 -0
  11. package/src/components/checkout/decline.ts +48 -0
  12. package/src/components/checkout/en-US.ts +33 -0
  13. package/src/components/checkout/hosted-return.ts +190 -206
  14. package/src/components/checkout/hosted-store.ts +269 -0
  15. package/src/components/checkout/payment-status-parts.tsx +311 -0
  16. package/src/components/checkout/payment-status.tsx +69 -264
  17. package/src/components/checkout/providers/types.ts +20 -3
  18. package/src/components/checkout/pt-BR.ts +33 -0
  19. package/src/components/checkout/screens-copy.ts +14 -0
  20. package/src/components/checkout/screens-en-US.ts +1 -0
  21. package/src/components/checkout/screens-pt-BR.ts +3 -0
  22. package/src/components/checkout/transport.ts +21 -1
  23. package/src/components/checkout/types.ts +24 -0
  24. package/src/components/checkout/use-card-checkout.ts +31 -31
  25. package/src/components/checkout/use-checkout-controller.ts +51 -271
  26. package/src/components/checkout/use-hosted-resume.ts +326 -0
  27. package/src/components/checkout/view-copy.ts +32 -0
  28. package/src/components/checkout/wallet-pane.tsx +3 -0
  29. package/src/flows/create-payment-flows.tsx +7 -0
  30. package/src/flows/screens-hosted.tsx +19 -2
  31. package/src/index.ts +22 -0
@@ -0,0 +1,269 @@
1
+ import type { CheckoutOrder } from "./types";
2
+
3
+ /**
4
+ * WHERE A CHECKOUT IN FLIGHT IS KEPT while the SPA is not (FUT-556, FUT-1140).
5
+ *
6
+ * A redirect provider takes the buyer to ITS OWN site, so the SPA is torn down
7
+ * and remounts fresh when they come back. A low-memory phone does the same
8
+ * thing without anybody leaving: the tab is discarded while the shopper is in
9
+ * their bank app, and the checkout that comes back has never heard of the order
10
+ * it raised. Everything the checkout held — which order, for how much, against
11
+ * which basket — is gone, and without it the return lands on an empty payment
12
+ * step: no confirmation, no total, no sign that the money they just moved
13
+ * arrived.
14
+ *
15
+ * The webhook still settles the order server-side; that is the mechanism and it
16
+ * does not depend on any of this. What is rescued here is only the buyer's view
17
+ * of it.
18
+ *
19
+ * `sessionStorage`, not `localStorage`: this is one tab's round trip, and a
20
+ * pending order left in durable storage would resurface in a later, unrelated
21
+ * session.
22
+ *
23
+ * This module is the STORAGE half only. Whether a parked entry may be resumed —
24
+ * which is a money rule, and the whole of FUT-1213 — lives in
25
+ * `./hosted-return.ts`.
26
+ */
27
+
28
+ /**
29
+ * Where the parked order lives, namespaced to this PACKAGE.
30
+ *
31
+ * It used to carry one adopter's brand as its namespace, written into every
32
+ * adopter's browser. A storage key is not a private detail: it is observable
33
+ * surface, asserted on by `@12-apps/payments-e2e` and visible in devtools to
34
+ * anyone running the host. The sibling handover in this same folder already got
35
+ * this right with a `payments:` prefix; this one did not.
36
+ *
37
+ * Exported so a host or a spec names it rather than retyping it.
38
+ */
39
+ export const HOSTED_ORDER_STORAGE_KEY = "payments.checkout.hostedOrder";
40
+
41
+ /**
42
+ * The key before the 2.0.0 rename, READ ONLY — decoded from base64 so no
43
+ * spelling of the old brand, whole or split, appears in shipped source (both
44
+ * brand gates sweep this file), while the RUNTIME string stays exactly what
45
+ * pre-2.0.0 bundles wrote.
46
+ *
47
+ * A buyer who left for the provider's page on a pre-2.0.0 bundle comes back
48
+ * to a newer one with their order parked under the old name. Without this
49
+ * they land on the plain return screen — the order still settles, because the
50
+ * webhook does that and never depended on any of this, but the confirmation
51
+ * they were promised is missing for a reason they could not possibly
52
+ * understand.
53
+ *
54
+ * DELETE when both hold, and not before:
55
+ * 1. every adopter's production has served ONLY >= 2.0.0 bundles for at
56
+ * least 24 hours (a hosted round trip lasts minutes; a day is
57
+ * over-margin) — verified against each consumer's lockfile history, not
58
+ * assumed from this package's release date; and
59
+ * 2. the deletion rides its own release with this note in the body, so an
60
+ * adopter still rolling back to a pre-2.0.0 bundle knows the window it
61
+ * reopens.
62
+ * 3.0.0 deleted this shim on the package's clock instead of the hosts' —
63
+ * consumers still pinned 2.x, so their key-renaming deploy had not happened
64
+ * yet — which is why it is back.
65
+ */
66
+ const LEGACY_KEY = atob("ZnV0dXJlcGF5LmNoZWNrb3V0Lmhvc3RlZE9yZGVy");
67
+
68
+ /**
69
+ * What is actually parked: the order, WHOSE STORE it belongs to, WHICH BASKET
70
+ * it was raised from, whether the buyer was sent away for it, and when.
71
+ *
72
+ * `CheckoutOrder` carries no tenant, and on a multi-tenant storefront every
73
+ * store shares one origin — so one tab holds one slot for all of them. Without
74
+ * the slug, a buyer who abandoned store A's hand-off and opened store B's
75
+ * checkout resumed A's order on B's screen: a confirmation for an unrelated
76
+ * order, and B's own checkout skipped.
77
+ *
78
+ * `basket` is the axis FUT-1213 added, and it is the one that decides whether
79
+ * this entry is still ABOUT anything: an order raised from a basket the shopper
80
+ * has since emptied and refilled is an order they are no longer placing. See
81
+ * `./basket.ts` for why it is a signature of the lines rather than a cart id.
82
+ *
83
+ * `handoff` records whether the buyer was sent to another site for this order.
84
+ * It decides WHERE a resume lands rather than whether one happens: a hand-off
85
+ * can only be confirmed by asking, so it resumes on the confirmation screen,
86
+ * while a PIX code raised on our own page is still the thing the buyer needs to
87
+ * look at.
88
+ *
89
+ * IT IS WRITTEN EXPLICITLY, both ways, and that is a compatibility rule rather
90
+ * than a style: every entry parked by a PRE-1140 bundle is a hand-off — those
91
+ * were the only two things that parked anything — so an ABSENT flag has to mean
92
+ * "hand-off", and a new entry that merely omitted `false` would be read as one
93
+ * too. A buyer resumed onto the payment step for a hand-off meets a spinner
94
+ * that never navigates and no poll, having had their parked entry consumed:
95
+ * the way out they actually take is a second order on a closed cart.
96
+ *
97
+ * `parkedAt` bounds the other axis. A checkout in flight is minutes; an entry
98
+ * older than {@link MAX_PARKED_AGE_MS} belongs to a session the buyer has long
99
+ * since abandoned, and resuming it tells them about an order they are no longer
100
+ * trying to place.
101
+ */
102
+ export interface ParkedHostedOrder {
103
+ order: CheckoutOrder;
104
+ /** The store this checkout belongs to; absent for an unscoped host. */
105
+ tenantSlug?: string;
106
+ /**
107
+ * The basket the order was raised from — `null` for an empty one, and ABSENT
108
+ * when the host supplied no identity at all (an older bundle, a host that has
109
+ * not wired it). Absent and `null` are deliberately different: absent means
110
+ * "unknown", which the rule treats as today's behaviour.
111
+ */
112
+ basket?: string | null;
113
+ /**
114
+ * The buyer was sent to the provider's own page for this order.
115
+ *
116
+ * ABSENT means an entry from a bundle that predates the flag, and every one
117
+ * of those IS a hand-off — see the note above. `false` is written out for a
118
+ * charge raised on our own page precisely so the two stay distinguishable.
119
+ */
120
+ handoff?: boolean;
121
+ parkedAt: number;
122
+ }
123
+
124
+ /**
125
+ * How long a parked checkout stays resumable.
126
+ *
127
+ * Thirty minutes: a hosted payment takes minutes, and the window has to cover a
128
+ * buyer who fetches their card, not one who comes back tomorrow. Beyond it the
129
+ * entry is dropped on read rather than resumed.
130
+ */
131
+ const MAX_PARKED_AGE_MS = 30 * 60_000;
132
+
133
+ /** What a caller states about the checkout it is parking. */
134
+ interface ParkedContext {
135
+ /** The store being paid. */
136
+ tenantSlug?: string;
137
+ /** The basket's signature — see {@link ParkedHostedOrder.basket}. */
138
+ basket?: string | null;
139
+ /** The buyer is being sent to the provider's own page. */
140
+ handoff?: boolean;
141
+ }
142
+
143
+ /** Park the raised order — before a hand-off, and on every raise (FUT-1140). */
144
+ export function rememberHostedOrder(order: CheckoutOrder, context: ParkedContext = {}): void {
145
+ try {
146
+ const parked: ParkedHostedOrder = {
147
+ order,
148
+ ...(context.tenantSlug ? { tenantSlug: context.tenantSlug } : {}),
149
+ ...(context.basket === undefined ? {} : { basket: context.basket }),
150
+ // ALWAYS written, both ways: an absent flag is reserved for entries this
151
+ // version did not write, and those are all hand-offs.
152
+ handoff: context.handoff === true,
153
+ parkedAt: Date.now(),
154
+ };
155
+ window.sessionStorage?.setItem(HOSTED_ORDER_STORAGE_KEY, JSON.stringify(parked));
156
+ } catch {
157
+ // Storage disabled or full. The redirect must still happen: the webhook
158
+ // settles the order either way, and refusing to send the buyer to pay
159
+ // would be a far worse failure than a plain return screen.
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The raw parked payload under either key.
165
+ *
166
+ * Split from the parsing so the two halves stay separately readable — they fail
167
+ * for unrelated reasons anyway (storage disabled vs. a value that is not an
168
+ * order).
169
+ */
170
+ function peekParkedPayload(): string | null {
171
+ try {
172
+ return (
173
+ window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
174
+ window.sessionStorage?.getItem(LEGACY_KEY) ??
175
+ null
176
+ );
177
+ } catch {
178
+ // Storage disabled or unavailable — the same "no parked order" as an empty
179
+ // slot, and the webhook still settles the order regardless.
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The parked entry, parsed, or null. Tolerates the PRE-SCOPE shape — a bare
186
+ * `CheckoutOrder` — so a buyer mid-flight across the deploy still comes back
187
+ * to their confirmation.
188
+ */
189
+ export function readParked(): ParkedHostedOrder | null {
190
+ const raw = peekParkedPayload();
191
+ if (!raw) return null;
192
+ try {
193
+ const parsed: unknown = JSON.parse(raw);
194
+ if (isCheckoutOrder(parsed)) return { order: parsed, parkedAt: Date.now() };
195
+ if (typeof parsed !== "object" || parsed === null) return null;
196
+ return scopedEntry(parsed as Partial<ParkedHostedOrder>);
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The SCOPED shape, field by field, with every absent one left absent.
204
+ *
205
+ * Absence is meaningful on two of these — a basket that was never recorded is
206
+ * not the same as an empty one, and a hand-off flag that is missing means a
207
+ * charge raised on our own page — so nothing here defaults a field into
208
+ * existence.
209
+ */
210
+ function scopedEntry(candidate: Partial<ParkedHostedOrder>): ParkedHostedOrder | null {
211
+ if (!isCheckoutOrder(candidate.order)) return null;
212
+ return {
213
+ order: candidate.order,
214
+ ...(candidate.tenantSlug ? { tenantSlug: candidate.tenantSlug } : {}),
215
+ ...(candidate.basket === undefined ? {} : { basket: candidate.basket }),
216
+ // Preserved as WRITTEN, `false` included — dropping it here would turn
217
+ // every on-page charge back into the legacy shape and resume it on the
218
+ // confirmation screen.
219
+ ...(candidate.handoff === undefined ? {} : { handoff: candidate.handoff === true }),
220
+ parkedAt: typeof candidate.parkedAt === "number" ? candidate.parkedAt : Date.now(),
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Trust nothing that came back out of storage: it is the only input here that
226
+ * did not come from this render, and a half-written or hand-edited value would
227
+ * otherwise reach the status view as an order.
228
+ */
229
+ function isCheckoutOrder(value: unknown): value is CheckoutOrder {
230
+ if (typeof value !== "object" || value === null) return false;
231
+ const candidate = value as Partial<CheckoutOrder>;
232
+ return typeof candidate.orderId === "string" && typeof candidate.totalLabel === "string";
233
+ }
234
+
235
+ /** Whether a parked checkout is this store's. */
236
+ export function belongsHere(parked: ParkedHostedOrder, tenantSlug?: string): boolean {
237
+ // An unscoped entry (a host that passes no slug, or one parked by an older
238
+ // bundle) stays readable by anyone — the single-tenant case, where there is
239
+ // no other store to confuse it with.
240
+ if (!parked.tenantSlug || !tenantSlug) return true;
241
+ return parked.tenantSlug === tenantSlug;
242
+ }
243
+
244
+ /** Whether it has been sitting long enough to no longer be this trip's. */
245
+ export function isStale(parked: ParkedHostedOrder): boolean {
246
+ if (typeof parked.parkedAt !== "number") return false;
247
+ return Date.now() - parked.parkedAt > MAX_PARKED_AGE_MS;
248
+ }
249
+
250
+ /**
251
+ * Drop the parked entry. Split from the read because the READ has to decide
252
+ * whose it is first — consuming another store's checkout was the bug the
253
+ * scoping exists to stop.
254
+ *
255
+ * BOTH keys, whichever answered: a legacy entry left behind would let a later
256
+ * return trip resume an order that was already consumed.
257
+ *
258
+ * Exported because the entry now outlives a single read (FUT-1213's deferred
259
+ * ask, FUT-1146's release, and the settle that ends a checkout normally), so
260
+ * callers other than the read need a way to say "this one is finished".
261
+ */
262
+ export function forgetHostedOrder(): void {
263
+ try {
264
+ window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
265
+ window.sessionStorage?.removeItem(LEGACY_KEY);
266
+ } catch {
267
+ // Storage disabled — there was nothing to clear.
268
+ }
269
+ }
@@ -0,0 +1,311 @@
1
+ import { Box } from "@mui/material";
2
+ import type { JSX } from "react";
3
+
4
+ import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
5
+ import type { CheckoutDecline } from "./decline";
6
+ import type { OrderStatus } from "./types";
7
+ import { useCheckoutComponents } from "./ui";
8
+ import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
9
+
10
+ /**
11
+ * The BLOCKS the last screen of checkout is made of — its headline, its paid
12
+ * receipt and its action row.
13
+ *
14
+ * Split out of `./payment-status.tsx` when the classified decline (FUT-1145)
15
+ * and the buyer's own release (FUT-1146) took that file past its size gate.
16
+ * The split is along the seam the screen already had: this module renders,
17
+ * `payment-status.tsx` decides WHICH of these a status and a wait add up to.
18
+ */
19
+
20
+ /**
21
+ * The per-outcome VISUAL grammar — icon and semantic tone. The heading and
22
+ * supporting line beside them come from {@link PaymentStatusCopy}: an icon is
23
+ * the component's own vocabulary, a sentence never is. (The FAILED support
24
+ * line's job — say "nothing was charged" plainly and first — and the
25
+ * timed-out wait's "do not pay again" now live with the host's words, where
26
+ * FUT-556's reasoning is documented on the copy port.)
27
+ */
28
+ interface OutcomeVisual {
29
+ icon: JSX.Element;
30
+ /** Semantic theme token — never a raw colour. */
31
+ tone: "success" | "danger" | "warning" | "neutral";
32
+ }
33
+
34
+ const OUTCOME_VISUAL: Record<OrderStatus, OutcomeVisual> = {
35
+ PAID: { icon: <CheckCircleOutlineIcon fontSize="large" />, tone: "success" },
36
+ AWAITING_PAYMENT: { icon: <ScheduleIcon fontSize="large" />, tone: "neutral" },
37
+ FAILED: { icon: <ErrorOutlineIcon fontSize="large" />, tone: "danger" },
38
+ EXPIRED: { icon: <ScheduleIcon fontSize="large" />, tone: "warning" },
39
+ };
40
+
41
+ const OUTCOME_COPY_KEY: Record<OrderStatus, keyof Pick<
42
+ PaymentStatusCopy,
43
+ "paid" | "awaiting" | "failed" | "expired"
44
+ >> = {
45
+ PAID: "paid",
46
+ AWAITING_PAYMENT: "awaiting",
47
+ FAILED: "failed",
48
+ EXPIRED: "expired",
49
+ };
50
+
51
+ const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
52
+ success: "success.main",
53
+ danger: "error.main",
54
+ warning: "warning.main",
55
+ neutral: "text.secondary",
56
+ };
57
+
58
+ /**
59
+ * The buyer's quotable reference.
60
+ *
61
+ * The order id is a uuid — unreadable over a phone call and impossible to copy
62
+ * by eye — so the screen shows its first block, uppercased. It is the real id's
63
+ * own prefix rather than a second number, so support can still find the order
64
+ * from what the buyer reads out.
65
+ */
66
+ function orderReference(orderId: string): string {
67
+ return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
68
+ }
69
+
70
+ /** How the wait itself is going, when it has not resolved into an outcome. */
71
+ export interface WaitState {
72
+ /** The bounded wall-clock wait elapsed — nothing further is scheduled. */
73
+ timedOut: boolean;
74
+ /** The last poll failed, and the wait is still running (FUT-1144). */
75
+ unreachable: boolean;
76
+ }
77
+
78
+ /**
79
+ * Which of AWAITING's three faces this is.
80
+ *
81
+ * STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
82
+ * A wait that ran its clock out while failing carries BOTH flags — the last
83
+ * poll's error is still the last thing that happened — and saying "we keep
84
+ * trying" over a wait nothing is scheduled for is precisely the lie this ticket
85
+ * exists to remove. The elapsed state is also the one carrying "não pague de
86
+ * novo", which is the sentence that matters most when we have stopped looking.
87
+ *
88
+ * Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
89
+ * not resolved, and calm-but-alert is the visual for that.
90
+ */
91
+ function awaitingFace(
92
+ copy: PaymentStatusCopy,
93
+ wait: WaitState,
94
+ ): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
95
+ if (wait.timedOut) {
96
+ return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
97
+ }
98
+ if (wait.unreachable) {
99
+ return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * What a REFUSED card says, as specifically as the server let us be (FUT-1145).
106
+ *
107
+ * Falls back to the generic refusal for a reason the host wrote no sentence for
108
+ * — including one this bundle has never heard of, which is an ordinary state
109
+ * when the server is a release ahead. The fallback is exactly the screen this
110
+ * ticket started from, so the worst case is no worse than before.
111
+ */
112
+ function failedOutcome(copy: PaymentStatusCopy, decline: CheckoutDecline | null): StatusOutcomeCopy {
113
+ const reason = decline?.reason;
114
+ // `?.` on the TABLE as well as the row: a host that has not written the block
115
+ // at all lands on `failed`, which is a sentence it did write. The optional
116
+ // chain is not a copy default — nothing is invented here — it is the
117
+ // difference between one unworded refusal and a `TypeError` that unmounts a
118
+ // live checkout. The type still REQUIRES the key, so a host that typechecks
119
+ // is told; the ones that do not are the reason this is defensive at all.
120
+ return (reason ? copy.declined?.[reason] : undefined) ?? copy.failed;
121
+ }
122
+
123
+ /** The headline block: icon, outcome, and one supporting line. */
124
+ export function OutcomeHero({
125
+ copy,
126
+ status,
127
+ wait,
128
+ decline,
129
+ }: {
130
+ copy: PaymentStatusCopy;
131
+ status: OrderStatus;
132
+ wait: WaitState;
133
+ decline: CheckoutDecline | null;
134
+ }): JSX.Element {
135
+ const { Text } = useCheckoutComponents();
136
+ const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
137
+ const visual = face
138
+ ? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
139
+ : OUTCOME_VISUAL[status];
140
+ const outcome = face
141
+ ? face.outcome
142
+ : status === "FAILED"
143
+ ? failedOutcome(copy, decline)
144
+ : copy[OUTCOME_COPY_KEY[status]];
145
+ return (
146
+ <Box
147
+ // `payment-paid` is load-bearing for the storefront journeys — it is how
148
+ // they assert the buyer actually got there. Each unsettled wait gets its
149
+ // OWN id rather than reusing `payment-awaiting_payment`: a test that
150
+ // cannot tell "still asking" from "stopped asking" from "cannot reach the
151
+ // payment" is a test that would pass against the spinner this replaced.
152
+ data-testid={
153
+ face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
154
+ }
155
+ sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
156
+ >
157
+ <Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
158
+ <Text variant="heading" size="md" weight="bold" as="h2">
159
+ {outcome.heading}
160
+ </Text>
161
+ <Text variant="body" size="sm" as="p" style={{ opacity: 0.75 }}>
162
+ {outcome.support}
163
+ </Text>
164
+ </Box>
165
+ );
166
+ }
167
+
168
+ /** One label/value row of the paid receipt block. */
169
+ function Fact({ label, value, testId }: { label: string; value: string; testId?: string }): JSX.Element {
170
+ const { Text } = useCheckoutComponents();
171
+ return (
172
+ <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
173
+ <Text variant="body" size="sm" as="span" style={{ opacity: 0.75 }}>
174
+ {label}
175
+ </Text>
176
+ <Text variant="body" size="sm" weight="bold" as="span" data-testid={testId}>
177
+ {value}
178
+ </Text>
179
+ </Box>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * What a paid buyer will want later: how much left their account, which order
185
+ * it was, and where the receipt went. Rendered only for PAID — on any other
186
+ * outcome these facts are either untrue or not yet knowable.
187
+ */
188
+ export function PaidFacts({
189
+ copy,
190
+ totalLabel,
191
+ orderId,
192
+ buyerEmail,
193
+ }: {
194
+ copy: PaymentStatusCopy;
195
+ totalLabel: string;
196
+ orderId?: string;
197
+ buyerEmail?: string;
198
+ }): JSX.Element {
199
+ return (
200
+ <Box
201
+ data-testid="payment-receipt"
202
+ sx={{
203
+ display: "flex",
204
+ flexDirection: "column",
205
+ gap: 1,
206
+ p: 2,
207
+ borderRadius: 2,
208
+ bgcolor: "action.hover",
209
+ }}
210
+ >
211
+ <Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
212
+ {orderId ? (
213
+ <Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
214
+ ) : null}
215
+ {buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
216
+ </Box>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Whether "Tentar novamente" may be offered for a refusal (FUT-1145).
222
+ *
223
+ * `retriable === false` is the provider's OWN verdict that another attempt with
224
+ * this instrument cannot succeed — attempts exhausted (10001), a cancelled
225
+ * recurring mandate (20118), a malformed request. Offering a retry there is
226
+ * offering a button that mints another failed order and shows the same screen
227
+ * again; on a card the issuer is already counting, it is worse than useless.
228
+ *
229
+ * SILENCE MEANS YES. An undefined verdict is a provider that offered no
230
+ * guidance, not a refusal to retry, and withholding the button on silence
231
+ * would strand a buyer whose card is fine.
232
+ */
233
+ function retryable(decline: CheckoutDecline | null): boolean {
234
+ return decline?.retriable !== false;
235
+ }
236
+
237
+ /** The next-action row: retry / regenerate / check-again, always back-to-menu. */
238
+ export function StatusActions({
239
+ copy,
240
+ status,
241
+ decline,
242
+ onRetry,
243
+ onRegenerate,
244
+ onCheckAgain,
245
+ onNotPaid,
246
+ onBackToMenu,
247
+ }: {
248
+ copy: PaymentStatusCopy;
249
+ status: OrderStatus;
250
+ decline: CheckoutDecline | null;
251
+ onRetry?: () => void;
252
+ onRegenerate?: () => void;
253
+ /**
254
+ * Offered only while the wait is unsettled AND not visibly working — the
255
+ * caller decides that; here it is simply present or absent. A button under a
256
+ * healthy spinner would invite a tap that changes nothing.
257
+ */
258
+ onCheckAgain?: () => void;
259
+ /** The buyer's "I did not pay" (FUT-1146) — present only while it applies. */
260
+ onNotPaid?: () => void;
261
+ onBackToMenu: () => void;
262
+ }): JSX.Element {
263
+ const { Button } = useCheckoutComponents();
264
+ return (
265
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
266
+ {onCheckAgain ? (
267
+ <Button
268
+ variant="solid"
269
+ color="primary"
270
+ size="lg"
271
+ onClick={onCheckAgain}
272
+ dataTestId="payment-check-again"
273
+ >
274
+ {copy.checkAgainAction}
275
+ </Button>
276
+ ) : null}
277
+ {onNotPaid ? (
278
+ <Button
279
+ variant="outline"
280
+ color="neutral"
281
+ size="lg"
282
+ onClick={onNotPaid}
283
+ dataTestId="payment-not-paid"
284
+ >
285
+ {copy.notPaidAction}
286
+ </Button>
287
+ ) : null}
288
+ {status === "FAILED" && onRetry && retryable(decline) ? (
289
+ <Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
290
+ {copy.retryAction}
291
+ </Button>
292
+ ) : null}
293
+ {status === "EXPIRED" && onRegenerate ? (
294
+ <Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
295
+ {copy.regenerateAction}
296
+ </Button>
297
+ ) : null}
298
+ <Button
299
+ // Full width and last, so the thumb lands on the same place in every
300
+ // outcome instead of hunting a button that moves with the state.
301
+ variant={status === "PAID" ? "solid" : "outline"}
302
+ color={status === "PAID" ? "primary" : "neutral"}
303
+ size="lg"
304
+ onClick={onBackToMenu}
305
+ dataTestId="payment-back-to-menu"
306
+ >
307
+ {copy.backAction}
308
+ </Button>
309
+ </Box>
310
+ );
311
+ }