@12-apps/payments-frontend 3.21.3 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/checkout/basket.ts +85 -0
- package/src/components/checkout/card-outcome.ts +81 -0
- package/src/components/checkout/card-view.tsx +62 -22
- package/src/components/checkout/checkout-actions.ts +341 -0
- package/src/components/checkout/checkout-flow.tsx +112 -18
- package/src/components/checkout/checkout-steps.tsx +149 -174
- package/src/components/checkout/checkout-totals.tsx +51 -0
- package/src/components/checkout/client-context.tsx +3 -0
- package/src/components/checkout/dados-step.tsx +141 -0
- package/src/components/checkout/decline.ts +48 -0
- package/src/components/checkout/en-US.ts +33 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +269 -0
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/poll-loop.ts +10 -12
- package/src/components/checkout/poll-rearm.ts +86 -0
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +33 -0
- package/src/components/checkout/screens-copy.ts +14 -0
- package/src/components/checkout/screens-en-US.ts +1 -0
- package/src/components/checkout/screens-pt-BR.ts +3 -0
- package/src/components/checkout/transport.ts +21 -1
- package/src/components/checkout/types.ts +24 -0
- package/src/components/checkout/use-card-checkout.ts +31 -31
- package/src/components/checkout/use-checkout-controller.ts +51 -271
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/view-copy.ts +32 -0
- package/src/components/checkout/wallet-pane.tsx +3 -0
- package/src/flows/create-payment-flows.tsx +7 -0
- package/src/flows/screens-hosted.tsx +19 -2
- 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
|
+
}
|