@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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
2
|
import type { JSX, ReactNode } from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { CheckoutDecline } from "./decline";
|
|
5
|
+
import { OutcomeHero, PaidFacts, StatusActions, type WaitState } from "./payment-status-parts";
|
|
5
6
|
import type { OrderStatus } from "./types";
|
|
6
7
|
import { useCheckoutComponents } from "./ui";
|
|
7
|
-
import type { PaymentStatusCopy
|
|
8
|
+
import type { PaymentStatusCopy } from "./view-copy";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The last screen of checkout.
|
|
@@ -29,241 +30,6 @@ import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
|
|
|
29
30
|
* screen says and how it is arranged has changed.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
|
-
/**
|
|
33
|
-
* The per-outcome VISUAL grammar — icon and semantic tone. The heading and
|
|
34
|
-
* supporting line beside them come from {@link PaymentStatusCopy}: an icon is
|
|
35
|
-
* the component's own vocabulary, a sentence never is. (The FAILED support
|
|
36
|
-
* line's job — say "nothing was charged" plainly and first — and the
|
|
37
|
-
* timed-out wait's "do not pay again" now live with the host's words, where
|
|
38
|
-
* FUT-556's reasoning is documented on the copy port.)
|
|
39
|
-
*/
|
|
40
|
-
interface OutcomeVisual {
|
|
41
|
-
icon: JSX.Element;
|
|
42
|
-
/** Semantic theme token — never a raw colour. */
|
|
43
|
-
tone: "success" | "danger" | "warning" | "neutral";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const OUTCOME_VISUAL: Record<OrderStatus, OutcomeVisual> = {
|
|
47
|
-
PAID: { icon: <CheckCircleOutlineIcon fontSize="large" />, tone: "success" },
|
|
48
|
-
AWAITING_PAYMENT: { icon: <ScheduleIcon fontSize="large" />, tone: "neutral" },
|
|
49
|
-
FAILED: { icon: <ErrorOutlineIcon fontSize="large" />, tone: "danger" },
|
|
50
|
-
EXPIRED: { icon: <ScheduleIcon fontSize="large" />, tone: "warning" },
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const OUTCOME_COPY_KEY: Record<OrderStatus, keyof Pick<
|
|
54
|
-
PaymentStatusCopy,
|
|
55
|
-
"paid" | "awaiting" | "failed" | "expired"
|
|
56
|
-
>> = {
|
|
57
|
-
PAID: "paid",
|
|
58
|
-
AWAITING_PAYMENT: "awaiting",
|
|
59
|
-
FAILED: "failed",
|
|
60
|
-
EXPIRED: "expired",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
|
|
64
|
-
success: "success.main",
|
|
65
|
-
danger: "error.main",
|
|
66
|
-
warning: "warning.main",
|
|
67
|
-
neutral: "text.secondary",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The buyer's quotable reference.
|
|
72
|
-
*
|
|
73
|
-
* The order id is a uuid — unreadable over a phone call and impossible to copy
|
|
74
|
-
* by eye — so the screen shows its first block, uppercased. It is the real id's
|
|
75
|
-
* own prefix rather than a second number, so support can still find the order
|
|
76
|
-
* from what the buyer reads out.
|
|
77
|
-
*/
|
|
78
|
-
function orderReference(orderId: string): string {
|
|
79
|
-
return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
|
|
80
|
-
}
|
|
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
|
-
|
|
116
|
-
/** The headline block: icon, outcome, and one supporting line. */
|
|
117
|
-
function OutcomeHero({
|
|
118
|
-
copy,
|
|
119
|
-
status,
|
|
120
|
-
wait,
|
|
121
|
-
}: {
|
|
122
|
-
copy: PaymentStatusCopy;
|
|
123
|
-
status: OrderStatus;
|
|
124
|
-
wait: WaitState;
|
|
125
|
-
}): JSX.Element {
|
|
126
|
-
const { Text } = useCheckoutComponents();
|
|
127
|
-
const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
|
|
128
|
-
const visual = face
|
|
129
|
-
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
|
|
130
|
-
: OUTCOME_VISUAL[status];
|
|
131
|
-
const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
|
|
132
|
-
return (
|
|
133
|
-
<Box
|
|
134
|
-
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
135
|
-
// they assert the buyer actually got there. Each unsettled wait gets its
|
|
136
|
-
// OWN id rather than reusing `payment-awaiting_payment`: a test that
|
|
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.
|
|
139
|
-
data-testid={
|
|
140
|
-
face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
|
|
141
|
-
}
|
|
142
|
-
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
143
|
-
>
|
|
144
|
-
<Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
|
|
145
|
-
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
146
|
-
{outcome.heading}
|
|
147
|
-
</Text>
|
|
148
|
-
<Text variant="body" size="sm" as="p" style={{ opacity: 0.75 }}>
|
|
149
|
-
{outcome.support}
|
|
150
|
-
</Text>
|
|
151
|
-
</Box>
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** One label/value row of the paid receipt block. */
|
|
156
|
-
function Fact({ label, value, testId }: { label: string; value: string; testId?: string }): JSX.Element {
|
|
157
|
-
const { Text } = useCheckoutComponents();
|
|
158
|
-
return (
|
|
159
|
-
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 2 }}>
|
|
160
|
-
<Text variant="body" size="sm" as="span" style={{ opacity: 0.75 }}>
|
|
161
|
-
{label}
|
|
162
|
-
</Text>
|
|
163
|
-
<Text variant="body" size="sm" weight="bold" as="span" data-testid={testId}>
|
|
164
|
-
{value}
|
|
165
|
-
</Text>
|
|
166
|
-
</Box>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* What a paid buyer will want later: how much left their account, which order
|
|
172
|
-
* it was, and where the receipt went. Rendered only for PAID — on any other
|
|
173
|
-
* outcome these facts are either untrue or not yet knowable.
|
|
174
|
-
*/
|
|
175
|
-
function PaidFacts({
|
|
176
|
-
copy,
|
|
177
|
-
totalLabel,
|
|
178
|
-
orderId,
|
|
179
|
-
buyerEmail,
|
|
180
|
-
}: {
|
|
181
|
-
copy: PaymentStatusCopy;
|
|
182
|
-
totalLabel: string;
|
|
183
|
-
orderId?: string;
|
|
184
|
-
buyerEmail?: string;
|
|
185
|
-
}): JSX.Element {
|
|
186
|
-
return (
|
|
187
|
-
<Box
|
|
188
|
-
data-testid="payment-receipt"
|
|
189
|
-
sx={{
|
|
190
|
-
display: "flex",
|
|
191
|
-
flexDirection: "column",
|
|
192
|
-
gap: 1,
|
|
193
|
-
p: 2,
|
|
194
|
-
borderRadius: 2,
|
|
195
|
-
bgcolor: "action.hover",
|
|
196
|
-
}}
|
|
197
|
-
>
|
|
198
|
-
<Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
|
|
199
|
-
{orderId ? (
|
|
200
|
-
<Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
|
|
201
|
-
) : null}
|
|
202
|
-
{buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
|
|
203
|
-
</Box>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** The next-action row: retry / regenerate / check-again, always back-to-menu. */
|
|
208
|
-
function StatusActions({
|
|
209
|
-
copy,
|
|
210
|
-
status,
|
|
211
|
-
onRetry,
|
|
212
|
-
onRegenerate,
|
|
213
|
-
onCheckAgain,
|
|
214
|
-
onBackToMenu,
|
|
215
|
-
}: {
|
|
216
|
-
copy: PaymentStatusCopy;
|
|
217
|
-
status: OrderStatus;
|
|
218
|
-
onRetry?: () => void;
|
|
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;
|
|
226
|
-
onBackToMenu: () => void;
|
|
227
|
-
}): JSX.Element {
|
|
228
|
-
const { Button } = useCheckoutComponents();
|
|
229
|
-
return (
|
|
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}
|
|
242
|
-
{status === "FAILED" && onRetry ? (
|
|
243
|
-
<Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
|
|
244
|
-
{copy.retryAction}
|
|
245
|
-
</Button>
|
|
246
|
-
) : null}
|
|
247
|
-
{status === "EXPIRED" && onRegenerate ? (
|
|
248
|
-
<Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
|
|
249
|
-
{copy.regenerateAction}
|
|
250
|
-
</Button>
|
|
251
|
-
) : null}
|
|
252
|
-
<Button
|
|
253
|
-
// Full width and last, so the thumb lands on the same place in every
|
|
254
|
-
// outcome instead of hunting a button that moves with the state.
|
|
255
|
-
variant={status === "PAID" ? "solid" : "outline"}
|
|
256
|
-
color={status === "PAID" ? "primary" : "neutral"}
|
|
257
|
-
size="lg"
|
|
258
|
-
onClick={onBackToMenu}
|
|
259
|
-
dataTestId="payment-back-to-menu"
|
|
260
|
-
>
|
|
261
|
-
{copy.backAction}
|
|
262
|
-
</Button>
|
|
263
|
-
</Box>
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
33
|
/** What the last screen of checkout is handed. */
|
|
268
34
|
interface PaymentStatusProps {
|
|
269
35
|
/** Every sentence and label this screen renders — the HOST's words. */
|
|
@@ -304,6 +70,20 @@ interface PaymentStatusProps {
|
|
|
304
70
|
* about when it can.
|
|
305
71
|
*/
|
|
306
72
|
onCheckAgain?: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* The buyer says they did not pay (FUT-1146). Present only while the wait is
|
|
75
|
+
* genuinely unsettled and the caller has something to release; rendered on
|
|
76
|
+
* AWAITING and nowhere else, because every other status has an answer already
|
|
77
|
+
* and this action is the one that manufactures one.
|
|
78
|
+
*/
|
|
79
|
+
onNotPaid?: () => void;
|
|
80
|
+
/** A release is in flight — the action stands down rather than repeating. */
|
|
81
|
+
releasing?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* WHY the charge was refused (FUT-1145). Read only on FAILED: it chooses the
|
|
84
|
+
* sentence, and it decides whether a retry is offered at all.
|
|
85
|
+
*/
|
|
86
|
+
decline?: CheckoutDecline | null;
|
|
307
87
|
}
|
|
308
88
|
|
|
309
89
|
/**
|
|
@@ -328,52 +108,77 @@ function offeredCheckAgain(
|
|
|
328
108
|
return stalled ? onCheckAgain : undefined;
|
|
329
109
|
}
|
|
330
110
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
111
|
+
/** What the props ADD UP TO — every branch this screen makes, made once. */
|
|
112
|
+
interface StatusView {
|
|
113
|
+
effective: OrderStatus;
|
|
114
|
+
wait: WaitState;
|
|
115
|
+
paid: boolean;
|
|
116
|
+
spinning: boolean;
|
|
117
|
+
decline: CheckoutDecline | null;
|
|
118
|
+
checkAgain: (() => void) | undefined;
|
|
119
|
+
notPaid: (() => void) | undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the props into that view.
|
|
124
|
+
*
|
|
125
|
+
* A pure function rather than a block inside the component, because the
|
|
126
|
+
* decisions and the markup are two different things to read — and because the
|
|
127
|
+
* component was over the complexity gate with all of them inlined.
|
|
128
|
+
*/
|
|
129
|
+
function statusView(props: PaymentStatusProps): StatusView {
|
|
130
|
+
const effective: OrderStatus = props.status ?? "AWAITING_PAYMENT";
|
|
131
|
+
const wait: WaitState = {
|
|
132
|
+
timedOut: props.awaitingTimedOut === true,
|
|
133
|
+
unreachable: (props.awaitingError ?? null) !== null,
|
|
134
|
+
};
|
|
348
135
|
const stalled = isStalled(effective, wait);
|
|
349
|
-
const
|
|
350
|
-
|
|
136
|
+
const awaiting = effective === "AWAITING_PAYMENT";
|
|
137
|
+
return {
|
|
138
|
+
effective,
|
|
139
|
+
wait,
|
|
140
|
+
paid: effective === "PAID",
|
|
141
|
+
spinning: awaiting && !stalled,
|
|
142
|
+
decline: props.decline ?? null,
|
|
143
|
+
checkAgain: offeredCheckAgain(stalled, props.onCheckAgain),
|
|
144
|
+
// Scoped to the unsettled wait, and stood down while its own request is
|
|
145
|
+
// out. A settled screen has its answer; a second tap would only ask again.
|
|
146
|
+
notPaid: awaiting && props.releasing !== true ? props.onNotPaid : undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function PaymentStatus(props: PaymentStatusProps): JSX.Element {
|
|
151
|
+
const { copy, totalLabel, orderId, buyerEmail, onRetry, onRegenerate, onBackToMenu } = props;
|
|
152
|
+
const { LoadingState } = useCheckoutComponents();
|
|
153
|
+
const view = statusView(props);
|
|
351
154
|
|
|
352
155
|
return (
|
|
353
156
|
<Box
|
|
354
157
|
data-testid="payment-status"
|
|
355
|
-
data-status={effective}
|
|
356
|
-
data-timed-out={
|
|
158
|
+
data-status={view.effective}
|
|
159
|
+
data-timed-out={view.wait.timedOut ? "true" : undefined}
|
|
357
160
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
358
161
|
>
|
|
359
|
-
<OutcomeHero copy={copy} status={effective} wait={wait} />
|
|
162
|
+
<OutcomeHero copy={copy} status={view.effective} wait={view.wait} decline={view.decline} />
|
|
360
163
|
|
|
361
|
-
{paid ? (
|
|
164
|
+
{view.paid ? (
|
|
362
165
|
<PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
363
166
|
) : null}
|
|
364
167
|
|
|
365
|
-
{paid ? paidExtra : null}
|
|
168
|
+
{view.paid ? props.paidExtra : null}
|
|
366
169
|
|
|
367
|
-
{spinning ? (
|
|
170
|
+
{view.spinning ? (
|
|
368
171
|
<LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
|
|
369
172
|
) : null}
|
|
370
173
|
|
|
371
174
|
<StatusActions
|
|
372
175
|
copy={copy}
|
|
373
|
-
status={effective}
|
|
176
|
+
status={view.effective}
|
|
177
|
+
decline={view.decline}
|
|
374
178
|
onRetry={onRetry}
|
|
375
179
|
onRegenerate={onRegenerate}
|
|
376
|
-
onCheckAgain={
|
|
180
|
+
onCheckAgain={view.checkAgain}
|
|
181
|
+
onNotPaid={view.notPaid}
|
|
377
182
|
onBackToMenu={onBackToMenu}
|
|
378
183
|
/>
|
|
379
184
|
</Box>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Result } from "../../result";
|
|
2
|
+
import { claimRearm } from "./poll-rearm";
|
|
2
3
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -70,16 +71,6 @@ export interface PollingOptions {
|
|
|
70
71
|
*/
|
|
71
72
|
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
* How long a re-arm must stay quiet after the last ask.
|
|
75
|
-
*
|
|
76
|
-
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
77
|
-
* and a shopper flicking between the bank app and the store fires them again
|
|
78
|
-
* per trip. This collapses a burst into one request without delaying it: the
|
|
79
|
-
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
80
|
-
*/
|
|
81
|
-
const REARM_QUIET_MS = 1_000;
|
|
82
|
-
|
|
83
74
|
/**
|
|
84
75
|
* How long ONE status ask may take before the loop stops waiting on it.
|
|
85
76
|
*
|
|
@@ -170,6 +161,8 @@ function newRun() {
|
|
|
170
161
|
healthy: 0,
|
|
171
162
|
startedAt: 0,
|
|
172
163
|
askedAt: 0,
|
|
164
|
+
/** Live asks abandoned by a re-arm since the last answer. See `claimRearm`. */
|
|
165
|
+
supersededAsks: 0,
|
|
173
166
|
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
174
167
|
/**
|
|
175
168
|
* The wall clock, as a timer rather than a check after an ask. `outOfTime`
|
|
@@ -215,6 +208,9 @@ function askTimeout(
|
|
|
215
208
|
* it has to be consulted against the delay it is about to sleep for.
|
|
216
209
|
*/
|
|
217
210
|
function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
|
|
211
|
+
// An answer got through, so the re-arm budget is no longer being spent into
|
|
212
|
+
// a void: refill it whether the answer was an error or a status.
|
|
213
|
+
run.supersededAsks = 0;
|
|
218
214
|
if (!result.ok) {
|
|
219
215
|
run.errors += 1;
|
|
220
216
|
sink.setError(result.error);
|
|
@@ -374,6 +370,7 @@ export function createPollLoop(
|
|
|
374
370
|
run.stopped = false;
|
|
375
371
|
run.errors = 0;
|
|
376
372
|
run.healthy = 0;
|
|
373
|
+
run.supersededAsks = 0;
|
|
377
374
|
run.startedAt = Date.now();
|
|
378
375
|
armDeadline(run, options, sink);
|
|
379
376
|
sink.setTimedOut(false);
|
|
@@ -382,10 +379,11 @@ export function createPollLoop(
|
|
|
382
379
|
},
|
|
383
380
|
poke: (): void => {
|
|
384
381
|
if (run.cancelled || run.stopped) return;
|
|
385
|
-
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
386
382
|
// Deliberately NOT gated on `inFlight`: the shopper who just came back
|
|
387
383
|
// from their bank app is exactly the case where the previous ask is a
|
|
388
|
-
// socket that died while the screen was hidden. Abandon it and ask now
|
|
384
|
+
// socket that died while the screen was hidden. Abandon it and ask now —
|
|
385
|
+
// but `claimRearm` bounds how many live asks that may abandon in a row.
|
|
386
|
+
if (!claimRearm(run)) return;
|
|
389
387
|
run.attempt += 1;
|
|
390
388
|
run.inFlight = false;
|
|
391
389
|
clearPending(run);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a re-arm may abandon an ask that is still in flight (FUT-1259).
|
|
3
|
+
*
|
|
4
|
+
* `visibilitychange` and `online` drive the loop's `poke`, and both fire for
|
|
5
|
+
* the same reason the wait exists: the shopper went to their bank app and came
|
|
6
|
+
* back, and the request left behind is quite likely a socket that died while
|
|
7
|
+
* the screen was hidden. Abandoning it is right. Abandoning it EVERY time is
|
|
8
|
+
* what this module bounds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The state a re-arm decision reads. Structural on purpose: the poll loop's own
|
|
13
|
+
* run object satisfies it, and typing it that way keeps this module free of an
|
|
14
|
+
* import back into the loop that imports it. Module-private — the loop passes
|
|
15
|
+
* its run and never names this type, so exporting it is an unused export.
|
|
16
|
+
*/
|
|
17
|
+
interface RearmState {
|
|
18
|
+
inFlight: boolean;
|
|
19
|
+
askedAt: number;
|
|
20
|
+
supersededAsks: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long a re-arm must stay quiet after the last ask.
|
|
25
|
+
*
|
|
26
|
+
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
27
|
+
* and a shopper flicking between the bank app and the store fires them again
|
|
28
|
+
* per trip. This collapses a burst into one request without delaying it: the
|
|
29
|
+
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
30
|
+
*/
|
|
31
|
+
const REARM_QUIET_MS = 1_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many live asks a re-arm may abandon before it has to let one finish.
|
|
35
|
+
*
|
|
36
|
+
* The quiet window alone looked sufficient and is not, because it is measured
|
|
37
|
+
* against the CURRENT ask rather than against the previous re-arm. Once a round
|
|
38
|
+
* trip runs longer than the window — which is the ordinary state of the flaky
|
|
39
|
+
* link this whole feature exists for — every re-arm supersedes an ask that was
|
|
40
|
+
* still alive. Nothing is ever absorbed, so `errors` never rises, so the 2.5s →
|
|
41
|
+
* 5s → 10s backoff never engages, and the wait sits near one request per window
|
|
42
|
+
* instead of decaying to one per ten.
|
|
43
|
+
*
|
|
44
|
+
* So the budget: after this many abandoned asks with no answer in between, a
|
|
45
|
+
* re-arm stands down and lets the in-flight ask either return or hit its own
|
|
46
|
+
* `askTimeoutMs`. Either outcome reaches `absorb`, which refills the budget, so
|
|
47
|
+
* this only bites while genuinely nothing is getting through.
|
|
48
|
+
*
|
|
49
|
+
* **What it does NOT bound: the re-arm rate itself.** A re-arm that finds the
|
|
50
|
+
* loop idle spends nothing, so on a link whose asks RESOLVE inside the window —
|
|
51
|
+
* a fast 500, or the immediate `TypeError: Load failed` iOS raises for a killed
|
|
52
|
+
* socket — six `online` events still cost six requests, exactly as before. That
|
|
53
|
+
* flavour is bounded by {@link REARM_QUIET_MS} alone and always was. This
|
|
54
|
+
* constant is about the ask that hangs, which is the one the quiet window
|
|
55
|
+
* could not see.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_SUPERSEDED_ASKS = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a re-arm may proceed, and account for it if it may.
|
|
61
|
+
*
|
|
62
|
+
* Named `claim` rather than `may…` because it MUTATES on success: a re-arm that
|
|
63
|
+
* abandons a live ask spends one of the budget above. A re-arm that finds no
|
|
64
|
+
* ask in flight — the loop is simply between ticks — spends nothing, since
|
|
65
|
+
* there is no request to amplify.
|
|
66
|
+
*
|
|
67
|
+
* That second rule is what makes a denial safe, and it is an invariant worth
|
|
68
|
+
* stating: `supersededAsks > 0` implies `inFlight`, because every transition to
|
|
69
|
+
* `inFlight === false` in a live run either passes through `absorb` (which
|
|
70
|
+
* refills) or is followed synchronously by a `tick`. So a re-arm arriving at an
|
|
71
|
+
* IDLE loop is never denied, whatever the budget reads.
|
|
72
|
+
*
|
|
73
|
+
* The cost of a denial, stated honestly: the fourth consecutive return from the
|
|
74
|
+
* bank app does NOT poll immediately. It waits for the in-flight ask to resolve
|
|
75
|
+
* or reach `askTimeoutMs` (15s by default), plus one backoff step — so worst
|
|
76
|
+
* case the buyer learns they paid roughly 17s later than they would have. That
|
|
77
|
+
* is the price of not hammering `/status` on a link that is answering nothing,
|
|
78
|
+
* and the buyer's own "Verificar de novo" (`restart`) resets the budget outright.
|
|
79
|
+
*/
|
|
80
|
+
export function claimRearm(run: RearmState): boolean {
|
|
81
|
+
if (Date.now() - run.askedAt < REARM_QUIET_MS) return false;
|
|
82
|
+
if (!run.inFlight) return true;
|
|
83
|
+
if (run.supersededAsks >= MAX_SUPERSEDED_ASKS) return false;
|
|
84
|
+
run.supersededAsks += 1;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { JSX } from "react";
|
|
17
17
|
|
|
18
|
+
import type { CheckoutBasketIdentity } from "../basket";
|
|
18
19
|
import type {
|
|
19
20
|
BuyerInfo,
|
|
20
21
|
CheckoutOrder,
|
|
21
22
|
CheckoutProviderConfig,
|
|
22
|
-
|
|
23
|
+
OnCheckoutResolved,
|
|
23
24
|
PaymentMethod,
|
|
24
25
|
} from "../types";
|
|
25
26
|
|
|
@@ -70,6 +71,19 @@ export interface ProviderCheckoutScreenProps {
|
|
|
70
71
|
tenantSlug?: string;
|
|
71
72
|
/** The shell's polling cadence, passed through so tests can shorten it. */
|
|
72
73
|
pollIntervalMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* The buyer is retrying a REFUSED card (FUT-1145), so no saved instrument is
|
|
76
|
+
* preselected: the one this would otherwise choose is the one that failed,
|
|
77
|
+
* and a retry that re-charges it is a second identical decline.
|
|
78
|
+
*/
|
|
79
|
+
freshInstrument?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* WHICH basket this checkout is for (FUT-1213). A card screen needs it
|
|
82
|
+
* because it can PARK an order of its own: a redirect-based 3-D Secure
|
|
83
|
+
* challenge is a hand-off like any other, and an entry parked without a
|
|
84
|
+
* basket (or without a store) resumes over any basket at any store.
|
|
85
|
+
*/
|
|
86
|
+
basket?: CheckoutBasketIdentity;
|
|
73
87
|
/**
|
|
74
88
|
* The host's Apple Pay merchant-validation port (FUT-472): exchange the
|
|
75
89
|
* session's `validationURL` for an Apple merchant session, SERVER-SIDE —
|
|
@@ -78,8 +92,11 @@ export interface ProviderCheckoutScreenProps {
|
|
|
78
92
|
* card form stays the way to pay.
|
|
79
93
|
*/
|
|
80
94
|
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
81
|
-
/**
|
|
82
|
-
|
|
95
|
+
/**
|
|
96
|
+
* A terminal status — the shell moves to Confirmação, carrying the refusal
|
|
97
|
+
* when the charge produced one (FUT-1145).
|
|
98
|
+
*/
|
|
99
|
+
onResolved: OnCheckoutResolved;
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
/**
|
|
@@ -40,9 +40,42 @@ export const PT_BR_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
|
|
|
40
40
|
"Continuamos tentando por aqui. Se você já pagou, não pague de novo — " +
|
|
41
41
|
"o pedido é confirmado assim que a operadora avisar.",
|
|
42
42
|
},
|
|
43
|
+
/**
|
|
44
|
+
* One refusal at a time, in the cardholder's own terms (FUT-1145).
|
|
45
|
+
*
|
|
46
|
+
* `UNKNOWN` is deliberately absent: with no recognised reason there is
|
|
47
|
+
* nothing specific to say, and `failed` above is already that sentence.
|
|
48
|
+
*/
|
|
49
|
+
declined: {
|
|
50
|
+
INSUFFICIENT_FUNDS: {
|
|
51
|
+
heading: "Não havia saldo ou limite",
|
|
52
|
+
support: "Nenhum valor foi cobrado. Tente outro cartão.",
|
|
53
|
+
},
|
|
54
|
+
CARD_DECLINED: {
|
|
55
|
+
heading: "Seu banco não autorizou o pagamento",
|
|
56
|
+
support: "Nenhum valor foi cobrado. Tente outro cartão ou fale com o seu banco.",
|
|
57
|
+
},
|
|
58
|
+
INVALID_CARD: {
|
|
59
|
+
heading: "Os dados do cartão não foram aceitos",
|
|
60
|
+
support: "Nenhum valor foi cobrado. Confira o número, a validade e o CVV, ou use outro cartão.",
|
|
61
|
+
},
|
|
62
|
+
EXPIRED_CARD: {
|
|
63
|
+
heading: "O cartão está vencido",
|
|
64
|
+
support: "Nenhum valor foi cobrado. Use um cartão com a validade em dia.",
|
|
65
|
+
},
|
|
66
|
+
FRAUD_SUSPECTED: {
|
|
67
|
+
heading: "O banco bloqueou esta compra por segurança",
|
|
68
|
+
support: "Nenhum valor foi cobrado. Fale com o seu banco ou use outro cartão.",
|
|
69
|
+
},
|
|
70
|
+
PROVIDER_ERROR: {
|
|
71
|
+
heading: "Não foi possível processar o pagamento agora",
|
|
72
|
+
support: "Nenhum valor foi cobrado. Tente de novo em alguns instantes.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
43
75
|
retryAction: "Tentar novamente",
|
|
44
76
|
regenerateAction: "Gerar novo código",
|
|
45
77
|
checkAgainAction: "Verificar de novo",
|
|
78
|
+
notPaidAction: "Não consegui pagar",
|
|
46
79
|
backAction: "Voltar ao cardápio",
|
|
47
80
|
amountLabel: "Valor pago",
|
|
48
81
|
referenceLabel: "Pedido",
|
|
@@ -217,4 +217,18 @@ export interface CheckoutScreensCopy {
|
|
|
217
217
|
validation: CheckoutValidationCopy;
|
|
218
218
|
/** The step wrapper's own wait, while the charge is being raised. */
|
|
219
219
|
generatingPayment: string;
|
|
220
|
+
/**
|
|
221
|
+
* The pay bar's money caption — "Total · 2 itens".
|
|
222
|
+
*
|
|
223
|
+
* A FUNCTION because it inflects on the count, which is a property of the
|
|
224
|
+
* language rather than of the checkout: one item and two items are not the
|
|
225
|
+
* same word, and they are not the same word in different ways per language.
|
|
226
|
+
*
|
|
227
|
+
* It was a hard-coded Portuguese template inside the component until
|
|
228
|
+
* FUT-1179, which is the ticket about a checkout that never showed the
|
|
229
|
+
* amount — so the fix put that caption on a SECOND step and made a
|
|
230
|
+
* single-locale string render twice. Everything else on both bars had been a
|
|
231
|
+
* host's word since FUT-760; this one had simply been missed.
|
|
232
|
+
*/
|
|
233
|
+
totalCaption(items: number): string;
|
|
220
234
|
}
|
|
@@ -100,4 +100,7 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
100
100
|
required: 'Campo obrigatório.',
|
|
101
101
|
},
|
|
102
102
|
generatingPayment: 'Gerando pagamento…',
|
|
103
|
+
// Verbatim what the component rendered before the key existed, so nothing
|
|
104
|
+
// changes for a Brazilian shopper.
|
|
105
|
+
totalCaption: (items) => `Total · ${items} ${items === 1 ? 'item' : 'itens'}`,
|
|
103
106
|
};
|