@12-apps/payments-frontend 3.21.4 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +387 -0
- package/src/components/checkout/checkout-flow.tsx +126 -24
- 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/confirmation-wait.ts +97 -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 +41 -0
- package/src/components/checkout/failure-codes.ts +82 -0
- package/src/components/checkout/hosted-return.ts +190 -206
- package/src/components/checkout/hosted-store.ts +291 -0
- package/src/components/checkout/payment-error-panel.tsx +9 -3
- package/src/components/checkout/payment-status-parts.tsx +311 -0
- package/src/components/checkout/payment-status.tsx +69 -264
- package/src/components/checkout/pix-view.tsx +97 -8
- package/src/components/checkout/poll-loop.ts +5 -3
- package/src/components/checkout/providers/types.ts +20 -3
- package/src/components/checkout/pt-BR.ts +42 -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 +35 -0
- package/src/components/checkout/use-card-checkout.ts +34 -33
- package/src/components/checkout/use-checkout-controller.ts +68 -274
- package/src/components/checkout/use-hosted-resume.ts +326 -0
- package/src/components/checkout/use-payment-polling.ts +58 -7
- package/src/components/checkout/use-wallet-charge.ts +24 -1
- package/src/components/checkout/view-copy.ts +70 -0
- package/src/components/checkout/wallet-pane.tsx +9 -1
- package/src/flows/catalog-exit.ts +33 -0
- package/src/flows/create-payment-flows.tsx +19 -1
- package/src/flows/pipeline/actions.tsx +104 -0
- package/src/flows/pipeline/admission.ts +55 -0
- package/src/flows/pipeline/context.ts +140 -0
- package/src/flows/pipeline/derive-step.ts +234 -0
- package/src/flows/pipeline/engine-actions.ts +257 -0
- package/src/flows/pipeline/engine-chrome.tsx +123 -0
- package/src/flows/pipeline/engine-state.ts +107 -0
- package/src/flows/pipeline/engine.tsx +377 -0
- package/src/flows/pipeline/methods.ts +71 -0
- package/src/flows/pipeline/refusal-routing.ts +106 -0
- package/src/flows/pipeline/slices.ts +110 -0
- package/src/flows/pipeline/stable-plugins.ts +72 -0
- package/src/flows/pipeline/steps/buyer-steps.tsx +297 -0
- package/src/flows/pipeline/steps/index.ts +54 -0
- package/src/flows/pipeline/steps/pay-steps.tsx +182 -0
- package/src/flows/pipeline/steps/status-step.tsx +41 -0
- package/src/flows/pipeline/types.ts +232 -0
- package/src/flows/public.ts +78 -0
- package/src/flows/screens-hosted.tsx +55 -5
- package/src/flows/screens-pay.tsx +6 -1
- package/src/flows/types.ts +25 -2
- package/src/index.ts +29 -19
|
@@ -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,9 +1,10 @@
|
|
|
1
1
|
import { Box } from "@mui/material";
|
|
2
|
-
import { useEffect, useState, type JSX } from "react";
|
|
2
|
+
import { useEffect, useMemo, useState, type JSX } from "react";
|
|
3
3
|
import QRCode from "react-qr-code";
|
|
4
4
|
|
|
5
5
|
import { useCheckoutCopy } from "./copy-context";
|
|
6
6
|
import { ContentCopyIcon } from "./icons";
|
|
7
|
+
import type { SettlingCopy } from "./screens-copy";
|
|
7
8
|
import { StalledWait } from "./stalled-wait";
|
|
8
9
|
import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
|
|
9
10
|
import { useCheckoutComponents } from "./ui";
|
|
@@ -16,6 +17,54 @@ import { usePaymentPolling } from "./use-payment-polling";
|
|
|
16
17
|
* the payment-status screen.
|
|
17
18
|
*/
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How many polls at the opening cadence before the wait decays (FUT-1170).
|
|
22
|
+
*
|
|
23
|
+
* Twelve is thirty seconds at the default 2.5 s, which is the window the common
|
|
24
|
+
* case lives in: a buyer on this screen has the QR in front of them and pays
|
|
25
|
+
* within a few taps, and the answer lands within seconds of the webhook.
|
|
26
|
+
* Everything after that window is an abandoned tab, or a shopper who has gone
|
|
27
|
+
* to fetch a different phone — and paying full cadence for it taxes the case
|
|
28
|
+
* that matters to subsidise the one that does not.
|
|
29
|
+
*/
|
|
30
|
+
const PIX_FAST_POLLS = 12;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The decayed cadence. Six asks a minute rather than twenty-four, and the two
|
|
34
|
+
* re-arm events (`visibilitychange`, `online`) still poll IMMEDIATELY when the
|
|
35
|
+
* buyer comes back from their bank app — which is the moment they are waiting
|
|
36
|
+
* on, so the slow phase costs them nothing there.
|
|
37
|
+
*/
|
|
38
|
+
const PIX_SLOW_INTERVAL_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long the wait outlives the code itself.
|
|
42
|
+
*
|
|
43
|
+
* The bound is the CHARGE's expiry, because that is the fact that ends this
|
|
44
|
+
* screen: after it, the server flips the order and answers a terminal EXPIRED.
|
|
45
|
+
* The grace is room for that flip to happen and be observed — stopping exactly
|
|
46
|
+
* at the expiry would end the wait one poll before the answer it exists for.
|
|
47
|
+
*/
|
|
48
|
+
const PIX_EXPIRY_GRACE_MS = 60_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The bound for a charge whose expiry cannot be read — a malformed instant, or
|
|
52
|
+
* an order that arrived without one.
|
|
53
|
+
*
|
|
54
|
+
* Unbounded is not the safe reading. It was the shipped one, and it is what let
|
|
55
|
+
* a forgotten tab ask a provider every 2.5 s for as long as it stayed open. A
|
|
56
|
+
* quarter of an hour is longer than any PIX code this checkout raises, and the
|
|
57
|
+
* buyer gets the ask back with one press.
|
|
58
|
+
*/
|
|
59
|
+
const PIX_FALLBACK_WAIT_MS = 15 * 60_000;
|
|
60
|
+
|
|
61
|
+
/** How long to keep asking about this charge — see the constants above. */
|
|
62
|
+
function pixWaitMs(expiresAt: string | undefined): number {
|
|
63
|
+
const deadline = expiresAt === undefined ? Number.NaN : Date.parse(expiresAt);
|
|
64
|
+
if (Number.isNaN(deadline)) return PIX_FALLBACK_WAIT_MS;
|
|
65
|
+
return Math.max(0, deadline - Date.now()) + PIX_EXPIRY_GRACE_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
19
68
|
/** The copyable "copia e cola" strip with its copy button. */
|
|
20
69
|
function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
21
70
|
const { Button, Text } = useCheckoutComponents();
|
|
@@ -72,35 +121,66 @@ function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
|
72
121
|
);
|
|
73
122
|
}
|
|
74
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Which of the wait's three faces the footer is showing — STOPPED, then STILL
|
|
126
|
+
* TRYING, then working.
|
|
127
|
+
*
|
|
128
|
+
* The card pane's order, and for its reason (`SubmittedState`): a wait that
|
|
129
|
+
* failed its way to its own clock carries both flags, and "we keep trying" over
|
|
130
|
+
* a wait nothing is scheduled for is the lie. The sentences are the shared
|
|
131
|
+
* {@link SettlingCopy} ones, so the two panes say the same thing about the same
|
|
132
|
+
* situation rather than drifting into two accounts of it.
|
|
133
|
+
*/
|
|
134
|
+
function pixWaitPanel(
|
|
135
|
+
copy: SettlingCopy,
|
|
136
|
+
error: string | null,
|
|
137
|
+
timedOut: boolean,
|
|
138
|
+
): { title: string; description: string; testId: string } | null {
|
|
139
|
+
if (timedOut) {
|
|
140
|
+
return { title: copy.takingLonger, description: copy.takingLongerHelp, testId: "pix-poll-timeout" };
|
|
141
|
+
}
|
|
142
|
+
if (error) return { title: copy.connectionLost, description: error, testId: "pix-poll-error" };
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
75
146
|
/**
|
|
76
147
|
* The live footer: the pulsing "awaiting payment" indicator, or — while the
|
|
77
|
-
* poll cannot reach us — the same wait said out loud,
|
|
148
|
+
* poll cannot reach us, or once it has stopped — the same wait said out loud,
|
|
149
|
+
* with a way to restart it.
|
|
78
150
|
*
|
|
79
151
|
* A WARNING rather than a danger (FUT-1144). The old red panel said "não foi
|
|
80
152
|
* possível confirmar o pagamento" and meant it: four consecutive failures ended
|
|
81
153
|
* the wait, so a shopper who paid during a ten-second blip watched a QR under a
|
|
82
154
|
* final-sounding refusal that would never update. The QR is still good, the
|
|
83
155
|
* wait is still running, and the sentence now says both.
|
|
156
|
+
*
|
|
157
|
+
* The elapsed face is FUT-1170's half. The PIX wait is bounded now, and a bound
|
|
158
|
+
* that stops the polling without changing what is on screen is the same silent
|
|
159
|
+
* failure one screen later: a pulsing dot beside "Aguardando pagamento…" for a
|
|
160
|
+
* wait that is no longer asking anything.
|
|
84
161
|
*/
|
|
85
162
|
function PixPollFooter({
|
|
86
163
|
error,
|
|
164
|
+
timedOut,
|
|
87
165
|
onCheckAgain,
|
|
88
166
|
}: {
|
|
89
167
|
error: string | null;
|
|
168
|
+
timedOut: boolean;
|
|
90
169
|
onCheckAgain: () => void;
|
|
91
170
|
}): JSX.Element {
|
|
92
171
|
const { Text } = useCheckoutComponents();
|
|
93
172
|
const { pix, settling } = useCheckoutCopy().screens;
|
|
94
|
-
|
|
173
|
+
const panel = pixWaitPanel(settling, error, timedOut);
|
|
174
|
+
if (panel) {
|
|
95
175
|
// The same panel the card and wallet panes show, held to the width of the
|
|
96
176
|
// copy-and-paste strip above it so the centred PIX column stays a column.
|
|
97
177
|
return (
|
|
98
178
|
<Box sx={{ width: "100%", maxWidth: 420 }}>
|
|
99
179
|
<StalledWait
|
|
100
|
-
title={
|
|
101
|
-
description={
|
|
180
|
+
title={panel.title}
|
|
181
|
+
description={panel.description}
|
|
102
182
|
onCheckAgain={onCheckAgain}
|
|
103
|
-
testId=
|
|
183
|
+
testId={panel.testId}
|
|
104
184
|
actionTestId="pix-check-again"
|
|
105
185
|
/>
|
|
106
186
|
</Box>
|
|
@@ -127,8 +207,17 @@ export function PixView({
|
|
|
127
207
|
}): JSX.Element {
|
|
128
208
|
const { Text } = useCheckoutComponents();
|
|
129
209
|
const copy = useCheckoutCopy().screens.pix;
|
|
130
|
-
|
|
210
|
+
// Fixed once per charge (FUT-1170): the bound is a span, so recomputing it
|
|
211
|
+
// every render would keep pushing the deadline out — and it is an effect
|
|
212
|
+
// dependency, so it would also restart the wait on every render.
|
|
213
|
+
const expiresAt = order.pix?.expiresAt;
|
|
214
|
+
const maxWaitMs = useMemo(() => pixWaitMs(expiresAt), [expiresAt]);
|
|
215
|
+
const { status, error, timedOut, checkAgain } = usePaymentPolling(order.orderId, {
|
|
131
216
|
intervalMs: pollIntervalMs,
|
|
217
|
+
slowAfterPolls: PIX_FAST_POLLS,
|
|
218
|
+
// Never FASTER than the opening cadence: a host that opens slowly means it.
|
|
219
|
+
slowIntervalMs: Math.max(pollIntervalMs, PIX_SLOW_INTERVAL_MS),
|
|
220
|
+
maxWaitMs,
|
|
132
221
|
});
|
|
133
222
|
|
|
134
223
|
// Bubble a terminal status up once, so the parent can advance to the status step.
|
|
@@ -182,7 +271,7 @@ export function PixView({
|
|
|
182
271
|
{copy.validUntil(validUntil)}
|
|
183
272
|
</Text>
|
|
184
273
|
|
|
185
|
-
<PixPollFooter error={error} onCheckAgain={checkAgain} />
|
|
274
|
+
<PixPollFooter error={error} timedOut={timedOut} onCheckAgain={checkAgain} />
|
|
186
275
|
</Box>
|
|
187
276
|
);
|
|
188
277
|
}
|
|
@@ -32,8 +32,10 @@ export interface PollingOptions {
|
|
|
32
32
|
/**
|
|
33
33
|
* WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
|
|
34
34
|
* `timedOut` once this many milliseconds have passed since the wait began.
|
|
35
|
-
* Undefined ⇒ unbounded
|
|
36
|
-
* server-side and comes back
|
|
35
|
+
* Undefined ⇒ unbounded, which no consumer here is any more. PIX passed none
|
|
36
|
+
* — its charge expires server-side and comes back terminal, true of a tab
|
|
37
|
+
* somebody is watching and not of one left open in a pocket, which asked a
|
|
38
|
+
* provider every 2.5 s for as long as it lived (FUT-1170).
|
|
37
39
|
*
|
|
38
40
|
* It used to be a count of HEALTHY polls, which measured the wrong thing in
|
|
39
41
|
* the only case that matters. A wait that is failing makes no healthy polls,
|
|
@@ -126,7 +128,7 @@ function pollDelay(healthy: number, errors: number, options: PollingOptions): nu
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/** Where a running wait writes what it has learned. */
|
|
129
|
-
interface PollSink {
|
|
131
|
+
export interface PollSink {
|
|
130
132
|
setStatus: (status: OrderStatus) => void;
|
|
131
133
|
setError: (error: string | null) => void;
|
|
132
134
|
setTimedOut: (timedOut: boolean) => void;
|
|
@@ -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",
|
|
@@ -92,4 +125,13 @@ export const PT_BR_CHECKOUT_VIEW_COPY: CheckoutViewCopy = {
|
|
|
92
125
|
action: "Ver cardápio",
|
|
93
126
|
},
|
|
94
127
|
status: PT_BR_PAYMENT_STATUS_COPY,
|
|
128
|
+
pipeline: {
|
|
129
|
+
loading: "Carregando…",
|
|
130
|
+
// Keyed by the settlement method's id. The package registers PIX and CARD;
|
|
131
|
+
// a host that registers another charged method adds its own line here.
|
|
132
|
+
awaitingHandover: {
|
|
133
|
+
PIX: "Abrindo o Pix…",
|
|
134
|
+
CARD: "Abrindo o pagamento com cartão…",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
95
137
|
};
|