@12-apps/payments-frontend 3.20.0 → 3.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/checkout/card-view.tsx +30 -28
- package/src/components/checkout/checkout-flow.tsx +5 -0
- package/src/components/checkout/en-US.ts +9 -0
- package/src/components/checkout/payment-status.tsx +130 -38
- package/src/components/checkout/pix-view.tsx +34 -12
- package/src/components/checkout/pt-BR.ts +7 -0
- package/src/components/checkout/screens-copy.ts +19 -0
- package/src/components/checkout/screens-en-US.ts +4 -0
- package/src/components/checkout/screens-pt-BR.ts +2 -0
- package/src/components/checkout/stalled-wait.tsx +58 -0
- package/src/components/checkout/use-card-checkout.ts +45 -11
- package/src/components/checkout/use-checkout-controller.ts +48 -13
- package/src/components/checkout/use-payment-polling.ts +250 -64
- package/src/components/checkout/use-wallet-charge.ts +20 -6
- package/src/components/checkout/view-copy.ts +15 -0
- package/src/components/checkout/wallet-pane.tsx +17 -16
- package/src/flows/copy.ts +19 -0
- package/src/flows/screens-hosted.tsx +72 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"storybook:build": "storybook build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@12-apps/payments-backend": "^4.26.
|
|
25
|
+
"@12-apps/payments-backend": "^4.26.1",
|
|
26
26
|
"react-qr-code": "^2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -10,45 +10,47 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import { useCheckoutCopy } from "./copy-context";
|
|
12
12
|
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
13
|
+
import { StalledWait } from "./stalled-wait";
|
|
13
14
|
import type { CardChainLink } from "./method-capability";
|
|
14
15
|
import type { BuyerInfo, CheckoutOrder, OrderStatus } from "./types";
|
|
15
16
|
import { useCheckoutComponents } from "./ui";
|
|
16
|
-
import { useCardCheckout } from "./use-card-checkout";
|
|
17
|
+
import { useCardCheckout, type CardCheckout } from "./use-card-checkout";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* Post-submit confirmation state
|
|
20
|
-
*
|
|
21
|
-
* order stays AWAITING server-side and is
|
|
22
|
-
* backfill
|
|
20
|
+
* Post-submit confirmation state: timeout > error > spinner.
|
|
21
|
+
*
|
|
22
|
+
* The elapsed wall-clock wait leads (the order stays AWAITING server-side and is
|
|
23
|
+
* recoverable by webhook/reconcile/backfill, which is what its copy says), then
|
|
24
|
+
* a poll that cannot reach us — a warning saying we are STILL TRYING, where
|
|
25
|
+
* FUT-1144 found a danger Alert over a wait that had actually given up — then
|
|
26
|
+
* the ordinary bounded spinner.
|
|
27
|
+
*
|
|
28
|
+
* That order inverted with the meaning of the two flags. An error used to BE the
|
|
29
|
+
* ending; now the clock is, and a wait that failed its way to the clock carries
|
|
30
|
+
* both. "We keep trying" over a wait nothing is scheduled for is the lie.
|
|
23
31
|
*/
|
|
24
|
-
function SubmittedState({
|
|
25
|
-
|
|
26
|
-
pollTimedOut,
|
|
27
|
-
}: {
|
|
28
|
-
pollError: string | null;
|
|
29
|
-
pollTimedOut: boolean;
|
|
30
|
-
}): JSX.Element {
|
|
31
|
-
const { Alert, LoadingState } = useCheckoutComponents();
|
|
32
|
+
function SubmittedState({ card }: { card: CardCheckout }): JSX.Element {
|
|
33
|
+
const { LoadingState } = useCheckoutComponents();
|
|
32
34
|
const copy = useCheckoutCopy().screens.settling;
|
|
33
|
-
if (
|
|
35
|
+
if (card.pollTimedOut) {
|
|
34
36
|
return (
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
<StalledWait
|
|
38
|
+
title={copy.takingLonger}
|
|
39
|
+
description={copy.takingLongerHelp}
|
|
40
|
+
onCheckAgain={card.pollCheckAgain}
|
|
41
|
+
testId="card-poll-timeout"
|
|
42
|
+
actionTestId="card-check-again"
|
|
41
43
|
/>
|
|
42
44
|
);
|
|
43
45
|
}
|
|
44
|
-
if (
|
|
46
|
+
if (card.pollError) {
|
|
45
47
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
<StalledWait
|
|
49
|
+
title={copy.connectionLost}
|
|
50
|
+
description={card.pollError}
|
|
51
|
+
onCheckAgain={card.pollCheckAgain}
|
|
52
|
+
testId="card-poll-error"
|
|
53
|
+
actionTestId="card-check-again"
|
|
52
54
|
/>
|
|
53
55
|
);
|
|
54
56
|
}
|
|
@@ -139,7 +141,7 @@ export function CardView({
|
|
|
139
141
|
const unresolved = cc.errorCode === UNRESOLVED_CODE;
|
|
140
142
|
|
|
141
143
|
if (cc.submitted) {
|
|
142
|
-
return <SubmittedState
|
|
144
|
+
return <SubmittedState card={cc} />;
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
return (
|
|
@@ -174,6 +174,11 @@ function StatusStep({
|
|
|
174
174
|
onBackToMenu={c.goToMenu}
|
|
175
175
|
paidExtra={confirmationExtra}
|
|
176
176
|
awaitingTimedOut={c.resumeTimedOut}
|
|
177
|
+
// The resumed leg's own trouble, and the way out of it (FUT-1144). Both
|
|
178
|
+
// are inert for a checkout that never left this tab — nothing was parked,
|
|
179
|
+
// so nothing is being polled here.
|
|
180
|
+
awaitingError={c.resumeError}
|
|
181
|
+
onCheckAgain={c.resumeCheckAgain}
|
|
177
182
|
/>
|
|
178
183
|
);
|
|
179
184
|
}
|
|
@@ -40,8 +40,17 @@ export const EN_US_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
|
|
|
40
40
|
"If you have already paid, the order is confirmed as soon as the provider tells us — " +
|
|
41
41
|
"do not pay again. You can close this screen.",
|
|
42
42
|
},
|
|
43
|
+
awaitingUnreachable: {
|
|
44
|
+
heading: "We cannot reach the payment right now",
|
|
45
|
+
// "do not pay again" leads the second sentence for the same reason it
|
|
46
|
+
// leads `awaitingTimedOut`: a second payment is the expensive mistake.
|
|
47
|
+
support:
|
|
48
|
+
"We are still trying. If you have already paid, do not pay again — " +
|
|
49
|
+
"the order is confirmed as soon as the provider tells us.",
|
|
50
|
+
},
|
|
43
51
|
retryAction: "Try again",
|
|
44
52
|
regenerateAction: "Generate a new code",
|
|
53
|
+
checkAgainAction: "Check again",
|
|
45
54
|
backAction: "Back to the menu",
|
|
46
55
|
amountLabel: "Amount paid",
|
|
47
56
|
referenceLabel: "Order",
|
|
@@ -4,7 +4,7 @@ import type { JSX, ReactNode } from "react";
|
|
|
4
4
|
import { CheckCircleOutlineIcon, ErrorOutlineIcon, ScheduleIcon } from "./icons";
|
|
5
5
|
import type { OrderStatus } from "./types";
|
|
6
6
|
import { useCheckoutComponents } from "./ui";
|
|
7
|
-
import type { PaymentStatusCopy } from "./view-copy";
|
|
7
|
+
import type { PaymentStatusCopy, StatusOutcomeCopy } from "./view-copy";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The last screen of checkout.
|
|
@@ -79,37 +79,65 @@ function orderReference(orderId: string): string {
|
|
|
79
79
|
return orderId.replace(/-/g, "").slice(0, 8).toUpperCase();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** How the wait itself is going, when it has not resolved into an outcome. */
|
|
83
|
+
interface WaitState {
|
|
84
|
+
/** The bounded wall-clock wait elapsed — nothing further is scheduled. */
|
|
85
|
+
timedOut: boolean;
|
|
86
|
+
/** The last poll failed, and the wait is still running (FUT-1144). */
|
|
87
|
+
unreachable: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which of AWAITING's three faces this is.
|
|
92
|
+
*
|
|
93
|
+
* STOPPED beats STILL TRYING, and the order is the whole honesty of the screen.
|
|
94
|
+
* A wait that ran its clock out while failing carries BOTH flags — the last
|
|
95
|
+
* poll's error is still the last thing that happened — and saying "we keep
|
|
96
|
+
* trying" over a wait nothing is scheduled for is precisely the lie this ticket
|
|
97
|
+
* exists to remove. The elapsed state is also the one carrying "não pague de
|
|
98
|
+
* novo", which is the sentence that matters most when we have stopped looking.
|
|
99
|
+
*
|
|
100
|
+
* Both keep AWAITING's neutral clock icon and take WARNING's tone: the order is
|
|
101
|
+
* not resolved, and calm-but-alert is the visual for that.
|
|
102
|
+
*/
|
|
103
|
+
function awaitingFace(
|
|
104
|
+
copy: PaymentStatusCopy,
|
|
105
|
+
wait: WaitState,
|
|
106
|
+
): { outcome: StatusOutcomeCopy; tone: OutcomeVisual["tone"]; testId: string } | null {
|
|
107
|
+
if (wait.timedOut) {
|
|
108
|
+
return { outcome: copy.awaitingTimedOut, tone: "warning", testId: "payment-awaiting-timeout" };
|
|
109
|
+
}
|
|
110
|
+
if (wait.unreachable) {
|
|
111
|
+
return { outcome: copy.awaitingUnreachable, tone: "warning", testId: "payment-awaiting-unreachable" };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
/** The headline block: icon, outcome, and one supporting line. */
|
|
83
117
|
function OutcomeHero({
|
|
84
118
|
copy,
|
|
85
119
|
status,
|
|
86
|
-
|
|
120
|
+
wait,
|
|
87
121
|
}: {
|
|
88
122
|
copy: PaymentStatusCopy;
|
|
89
123
|
status: OrderStatus;
|
|
90
|
-
|
|
124
|
+
wait: WaitState;
|
|
91
125
|
}): JSX.Element {
|
|
92
126
|
const { Text } = useCheckoutComponents();
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const visual = timedOutWait
|
|
97
|
-
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
|
|
127
|
+
const face = status === "AWAITING_PAYMENT" ? awaitingFace(copy, wait) : null;
|
|
128
|
+
const visual = face
|
|
129
|
+
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: face.tone }
|
|
98
130
|
: OUTCOME_VISUAL[status];
|
|
99
|
-
const outcome =
|
|
131
|
+
const outcome = face ? face.outcome : copy[OUTCOME_COPY_KEY[status]];
|
|
100
132
|
return (
|
|
101
133
|
<Box
|
|
102
134
|
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
103
|
-
// they assert the buyer actually got there.
|
|
135
|
+
// they assert the buyer actually got there. Each unsettled wait gets its
|
|
104
136
|
// OWN id rather than reusing `payment-awaiting_payment`: a test that
|
|
105
|
-
// cannot tell "still asking" from "stopped asking"
|
|
106
|
-
// pass against the
|
|
137
|
+
// cannot tell "still asking" from "stopped asking" from "cannot reach the
|
|
138
|
+
// payment" is a test that would pass against the spinner this replaced.
|
|
107
139
|
data-testid={
|
|
108
|
-
|
|
109
|
-
? "payment-awaiting-timeout"
|
|
110
|
-
: status === "PAID"
|
|
111
|
-
? "payment-paid"
|
|
112
|
-
: `payment-${status.toLowerCase()}`
|
|
140
|
+
face ? face.testId : status === "PAID" ? "payment-paid" : `payment-${status.toLowerCase()}`
|
|
113
141
|
}
|
|
114
142
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
115
143
|
>
|
|
@@ -176,23 +204,41 @@ function PaidFacts({
|
|
|
176
204
|
);
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
/** The next-action row: retry / regenerate
|
|
207
|
+
/** The next-action row: retry / regenerate / check-again, always back-to-menu. */
|
|
180
208
|
function StatusActions({
|
|
181
209
|
copy,
|
|
182
210
|
status,
|
|
183
211
|
onRetry,
|
|
184
212
|
onRegenerate,
|
|
213
|
+
onCheckAgain,
|
|
185
214
|
onBackToMenu,
|
|
186
215
|
}: {
|
|
187
216
|
copy: PaymentStatusCopy;
|
|
188
217
|
status: OrderStatus;
|
|
189
218
|
onRetry?: () => void;
|
|
190
219
|
onRegenerate?: () => void;
|
|
220
|
+
/**
|
|
221
|
+
* Offered only while the wait is unsettled AND not visibly working — the
|
|
222
|
+
* caller decides that; here it is simply present or absent. A button under a
|
|
223
|
+
* healthy spinner would invite a tap that changes nothing.
|
|
224
|
+
*/
|
|
225
|
+
onCheckAgain?: () => void;
|
|
191
226
|
onBackToMenu: () => void;
|
|
192
227
|
}): JSX.Element {
|
|
193
228
|
const { Button } = useCheckoutComponents();
|
|
194
229
|
return (
|
|
195
230
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
231
|
+
{onCheckAgain ? (
|
|
232
|
+
<Button
|
|
233
|
+
variant="solid"
|
|
234
|
+
color="primary"
|
|
235
|
+
size="lg"
|
|
236
|
+
onClick={onCheckAgain}
|
|
237
|
+
dataTestId="payment-check-again"
|
|
238
|
+
>
|
|
239
|
+
{copy.checkAgainAction}
|
|
240
|
+
</Button>
|
|
241
|
+
) : null}
|
|
196
242
|
{status === "FAILED" && onRetry ? (
|
|
197
243
|
<Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
|
|
198
244
|
{copy.retryAction}
|
|
@@ -218,18 +264,8 @@ function StatusActions({
|
|
|
218
264
|
);
|
|
219
265
|
}
|
|
220
266
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
status,
|
|
224
|
-
totalLabel,
|
|
225
|
-
orderId,
|
|
226
|
-
buyerEmail,
|
|
227
|
-
onRetry,
|
|
228
|
-
onRegenerate,
|
|
229
|
-
onBackToMenu,
|
|
230
|
-
paidExtra,
|
|
231
|
-
awaitingTimedOut = false,
|
|
232
|
-
}: {
|
|
267
|
+
/** What the last screen of checkout is handed. */
|
|
268
|
+
interface PaymentStatusProps {
|
|
233
269
|
/** Every sentence and label this screen renders — the HOST's words. */
|
|
234
270
|
copy: PaymentStatusCopy;
|
|
235
271
|
status: OrderStatus | null;
|
|
@@ -249,14 +285,69 @@ export function PaymentStatus({
|
|
|
249
285
|
*/
|
|
250
286
|
paidExtra?: ReactNode;
|
|
251
287
|
/**
|
|
252
|
-
* The wait has been given up on
|
|
253
|
-
*
|
|
254
|
-
*
|
|
288
|
+
* The wait has been given up on. Only meaningful while AWAITING_PAYMENT;
|
|
289
|
+
* every other status has already resolved, so a stale flag cannot change what
|
|
290
|
+
* a settled screen says.
|
|
255
291
|
*/
|
|
256
292
|
awaitingTimedOut?: boolean;
|
|
257
|
-
|
|
293
|
+
/**
|
|
294
|
+
* The wait's last poll failed (FUT-1144). Same scope rule as
|
|
295
|
+
* {@link awaitingTimedOut}, and it YIELDS to it: a wait that failed its way to
|
|
296
|
+
* the wall clock has both, and the honest thing to say then is that we have
|
|
297
|
+
* stopped asking.
|
|
298
|
+
*/
|
|
299
|
+
awaitingError?: string | null;
|
|
300
|
+
/**
|
|
301
|
+
* Ask now. Rendered ONLY while the automatic wait is not visibly working —
|
|
302
|
+
* unreachable, or elapsed — so the buyer always has something to press when
|
|
303
|
+
* the spinner cannot honestly stand for progress, and nothing extra to think
|
|
304
|
+
* about when it can.
|
|
305
|
+
*/
|
|
306
|
+
onCheckAgain?: () => void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The wait has stopped LOOKING like progress: it cannot reach us, or it has run
|
|
311
|
+
* its clock out. Either way the spinner would be a lie and the buyer is owed
|
|
312
|
+
* something to press. Scoped to AWAITING because every other status has already
|
|
313
|
+
* resolved, so a stale flag cannot change what a settled screen says.
|
|
314
|
+
*/
|
|
315
|
+
function isStalled(status: OrderStatus, wait: WaitState): boolean {
|
|
316
|
+
if (status !== "AWAITING_PAYMENT") return false;
|
|
317
|
+
return wait.timedOut || wait.unreachable;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The check-again action, but only where it can honestly be offered — a button
|
|
322
|
+
* under a healthy spinner invites a tap that changes nothing.
|
|
323
|
+
*/
|
|
324
|
+
function offeredCheckAgain(
|
|
325
|
+
stalled: boolean,
|
|
326
|
+
onCheckAgain: (() => void) | undefined,
|
|
327
|
+
): (() => void) | undefined {
|
|
328
|
+
return stalled ? onCheckAgain : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function PaymentStatus({
|
|
332
|
+
copy,
|
|
333
|
+
status,
|
|
334
|
+
totalLabel,
|
|
335
|
+
orderId,
|
|
336
|
+
buyerEmail,
|
|
337
|
+
onRetry,
|
|
338
|
+
onRegenerate,
|
|
339
|
+
onBackToMenu,
|
|
340
|
+
paidExtra,
|
|
341
|
+
awaitingTimedOut = false,
|
|
342
|
+
awaitingError = null,
|
|
343
|
+
onCheckAgain,
|
|
344
|
+
}: PaymentStatusProps): JSX.Element {
|
|
258
345
|
const { LoadingState } = useCheckoutComponents();
|
|
259
346
|
const effective: OrderStatus = status ?? "AWAITING_PAYMENT";
|
|
347
|
+
const wait: WaitState = { timedOut: awaitingTimedOut, unreachable: awaitingError !== null };
|
|
348
|
+
const stalled = isStalled(effective, wait);
|
|
349
|
+
const paid = effective === "PAID";
|
|
350
|
+
const spinning = effective === "AWAITING_PAYMENT" && !stalled;
|
|
260
351
|
|
|
261
352
|
return (
|
|
262
353
|
<Box
|
|
@@ -265,15 +356,15 @@ export function PaymentStatus({
|
|
|
265
356
|
data-timed-out={awaitingTimedOut ? "true" : undefined}
|
|
266
357
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
267
358
|
>
|
|
268
|
-
<OutcomeHero copy={copy} status={effective}
|
|
359
|
+
<OutcomeHero copy={copy} status={effective} wait={wait} />
|
|
269
360
|
|
|
270
|
-
{
|
|
361
|
+
{paid ? (
|
|
271
362
|
<PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
272
363
|
) : null}
|
|
273
364
|
|
|
274
|
-
{
|
|
365
|
+
{paid ? paidExtra : null}
|
|
275
366
|
|
|
276
|
-
{
|
|
367
|
+
{spinning ? (
|
|
277
368
|
<LoadingState variant="spinner" size="md" message="" dataTestId="payment-pending" />
|
|
278
369
|
) : null}
|
|
279
370
|
|
|
@@ -282,6 +373,7 @@ export function PaymentStatus({
|
|
|
282
373
|
status={effective}
|
|
283
374
|
onRetry={onRetry}
|
|
284
375
|
onRegenerate={onRegenerate}
|
|
376
|
+
onCheckAgain={offeredCheckAgain(stalled, onCheckAgain)}
|
|
285
377
|
onBackToMenu={onBackToMenu}
|
|
286
378
|
/>
|
|
287
379
|
</Box>
|
|
@@ -4,6 +4,7 @@ import QRCode from "react-qr-code";
|
|
|
4
4
|
|
|
5
5
|
import { useCheckoutCopy } from "./copy-context";
|
|
6
6
|
import { ContentCopyIcon } from "./icons";
|
|
7
|
+
import { StalledWait } from "./stalled-wait";
|
|
7
8
|
import type { CheckoutOrder, OrderStatus, PixCharge } from "./types";
|
|
8
9
|
import { useCheckoutComponents } from "./ui";
|
|
9
10
|
import { usePaymentPolling } from "./use-payment-polling";
|
|
@@ -71,19 +72,38 @@ function PixCodeBox({ pix }: { pix: PixCharge }): JSX.Element {
|
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The live footer: the pulsing "awaiting payment" indicator, or — while the
|
|
77
|
+
* poll cannot reach us — the same wait said out loud, with a way to hurry it.
|
|
78
|
+
*
|
|
79
|
+
* A WARNING rather than a danger (FUT-1144). The old red panel said "não foi
|
|
80
|
+
* possível confirmar o pagamento" and meant it: four consecutive failures ended
|
|
81
|
+
* the wait, so a shopper who paid during a ten-second blip watched a QR under a
|
|
82
|
+
* final-sounding refusal that would never update. The QR is still good, the
|
|
83
|
+
* wait is still running, and the sentence now says both.
|
|
84
|
+
*/
|
|
85
|
+
function PixPollFooter({
|
|
86
|
+
error,
|
|
87
|
+
onCheckAgain,
|
|
88
|
+
}: {
|
|
89
|
+
error: string | null;
|
|
90
|
+
onCheckAgain: () => void;
|
|
91
|
+
}): JSX.Element {
|
|
92
|
+
const { Text } = useCheckoutComponents();
|
|
77
93
|
const { pix, settling } = useCheckoutCopy().screens;
|
|
78
94
|
if (error) {
|
|
95
|
+
// The same panel the card and wallet panes show, held to the width of the
|
|
96
|
+
// copy-and-paste strip above it so the centred PIX column stays a column.
|
|
79
97
|
return (
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
<Box sx={{ width: "100%", maxWidth: 420 }}>
|
|
99
|
+
<StalledWait
|
|
100
|
+
title={settling.connectionLost}
|
|
101
|
+
description={error}
|
|
102
|
+
onCheckAgain={onCheckAgain}
|
|
103
|
+
testId="pix-poll-error"
|
|
104
|
+
actionTestId="pix-check-again"
|
|
105
|
+
/>
|
|
106
|
+
</Box>
|
|
87
107
|
);
|
|
88
108
|
}
|
|
89
109
|
return (
|
|
@@ -107,7 +127,9 @@ export function PixView({
|
|
|
107
127
|
}): JSX.Element {
|
|
108
128
|
const { Text } = useCheckoutComponents();
|
|
109
129
|
const copy = useCheckoutCopy().screens.pix;
|
|
110
|
-
const { status, error } = usePaymentPolling(order.orderId, {
|
|
130
|
+
const { status, error, checkAgain } = usePaymentPolling(order.orderId, {
|
|
131
|
+
intervalMs: pollIntervalMs,
|
|
132
|
+
});
|
|
111
133
|
|
|
112
134
|
// Bubble a terminal status up once, so the parent can advance to the status step.
|
|
113
135
|
useEffect(() => {
|
|
@@ -160,7 +182,7 @@ export function PixView({
|
|
|
160
182
|
{copy.validUntil(validUntil)}
|
|
161
183
|
</Text>
|
|
162
184
|
|
|
163
|
-
<PixPollFooter error={error} />
|
|
185
|
+
<PixPollFooter error={error} onCheckAgain={checkAgain} />
|
|
164
186
|
</Box>
|
|
165
187
|
);
|
|
166
188
|
}
|
|
@@ -34,8 +34,15 @@ export const PT_BR_PAYMENT_STATUS_COPY: PaymentStatusCopy = {
|
|
|
34
34
|
"Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
|
|
35
35
|
"não pague de novo. Você pode fechar esta tela.",
|
|
36
36
|
},
|
|
37
|
+
awaitingUnreachable: {
|
|
38
|
+
heading: "Não conseguimos falar com o pagamento agora",
|
|
39
|
+
support:
|
|
40
|
+
"Continuamos tentando por aqui. Se você já pagou, não pague de novo — " +
|
|
41
|
+
"o pedido é confirmado assim que a operadora avisar.",
|
|
42
|
+
},
|
|
37
43
|
retryAction: "Tentar novamente",
|
|
38
44
|
regenerateAction: "Gerar novo código",
|
|
45
|
+
checkAgainAction: "Verificar de novo",
|
|
39
46
|
backAction: "Voltar ao cardápio",
|
|
40
47
|
amountLabel: "Valor pago",
|
|
41
48
|
referenceLabel: "Pedido",
|
|
@@ -61,6 +61,25 @@ export interface SettlingCopy {
|
|
|
61
61
|
confirming: string;
|
|
62
62
|
/** It came back refused. */
|
|
63
63
|
cannotPay: string;
|
|
64
|
+
/**
|
|
65
|
+
* We cannot reach the payment right now — a dropped connection, a handset
|
|
66
|
+
* moving between Wi-Fi and 4G, a browser that aborted our requests while the
|
|
67
|
+
* shopper was in their bank app (FUT-1144).
|
|
68
|
+
*
|
|
69
|
+
* TRANSIENT, and the sentence must say so: the screen is still asking, on a
|
|
70
|
+
* backoff, and it re-asks the moment the tab comes back or the signal
|
|
71
|
+
* returns. This was `cannotConfirm` — "não foi possível confirmar o
|
|
72
|
+
* pagamento" — under which the wait had actually STOPPED, so a shopper who
|
|
73
|
+
* had paid read a final-sounding refusal and was never told otherwise.
|
|
74
|
+
*/
|
|
75
|
+
connectionLost: string;
|
|
76
|
+
/**
|
|
77
|
+
* Ask again, now. Offered beside {@link connectionLost} and beside the
|
|
78
|
+
* elapsed wait, because a shopper watching a screen that cannot reach us
|
|
79
|
+
* needs something to press — and because pressing it is what restarts a wait
|
|
80
|
+
* that has run out.
|
|
81
|
+
*/
|
|
82
|
+
checkAgainAction: string;
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
/** The PIX pane: the QR, the copyable code, and the wait. */
|
|
@@ -35,6 +35,10 @@ export const EN_US_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
35
35
|
processing: 'Processing payment…',
|
|
36
36
|
confirming: 'We are confirming your payment',
|
|
37
37
|
cannotPay: 'Could not pay',
|
|
38
|
+
// "we keep trying" is the load-bearing half: the wait has not ended, and a
|
|
39
|
+
// shopper who reads a final-sounding refusal pays a second time.
|
|
40
|
+
connectionLost: 'No connection right now — we keep trying',
|
|
41
|
+
checkAgainAction: 'Check again',
|
|
38
42
|
},
|
|
39
43
|
pix: {
|
|
40
44
|
heading: 'Pay with PIX',
|
|
@@ -26,6 +26,8 @@ export const PT_BR_CHECKOUT_SCREENS_COPY: CheckoutScreensCopy = {
|
|
|
26
26
|
processing: 'Processando pagamento…',
|
|
27
27
|
confirming: 'Estamos confirmando seu pagamento',
|
|
28
28
|
cannotPay: 'Não foi possível pagar',
|
|
29
|
+
connectionLost: 'Sem conexão no momento — continuamos tentando',
|
|
30
|
+
checkAgainAction: 'Verificar de novo',
|
|
29
31
|
},
|
|
30
32
|
pix: {
|
|
31
33
|
heading: 'Pague com PIX',
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Box } from "@mui/material";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
5
|
+
import { useCheckoutComponents } from "./ui";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A wait that has stopped LOOKING like progress, said as a warning with the
|
|
9
|
+
* buyer's own "ask now" underneath (FUT-1144).
|
|
10
|
+
*
|
|
11
|
+
* Two situations reach it and they are deliberately the same shape: the poll
|
|
12
|
+
* cannot reach us, or the wall-clock wait has run out. In both, the confirmation
|
|
13
|
+
* spinner would be telling the buyer something is happening when nothing is —
|
|
14
|
+
* and by this point in the card and wallet panes every pay control is already
|
|
15
|
+
* gone, so without this button the screen has no control at all while it reports
|
|
16
|
+
* a problem. Pressing it restarts the wait, which is what makes it worth
|
|
17
|
+
* offering after a timeout and not only during a blip.
|
|
18
|
+
*
|
|
19
|
+
* A WARNING rather than a danger, in both. Neither says the payment failed:
|
|
20
|
+
* the charge is recoverable by webhook, reconciliation or backfill, and a red
|
|
21
|
+
* panel over a recoverable charge is what pushes a buyer into paying twice.
|
|
22
|
+
*
|
|
23
|
+
* One component for the card and the wallet because it is one decision. The
|
|
24
|
+
* panes' own confirmation states were near-identical before this and drifted
|
|
25
|
+
* apart in exactly the way that ends with a product telling a buyer two
|
|
26
|
+
* different things about one situation depending on which button they pressed.
|
|
27
|
+
*/
|
|
28
|
+
export function StalledWait({
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
onCheckAgain,
|
|
32
|
+
testId,
|
|
33
|
+
actionTestId,
|
|
34
|
+
}: {
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
onCheckAgain: () => void;
|
|
38
|
+
/** The alert's id — each pane names its own situation for its own suites. */
|
|
39
|
+
testId: string;
|
|
40
|
+
actionTestId: string;
|
|
41
|
+
}): JSX.Element {
|
|
42
|
+
const { Alert, Button } = useCheckoutComponents();
|
|
43
|
+
const copy = useCheckoutCopy().screens.settling;
|
|
44
|
+
return (
|
|
45
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
46
|
+
<Alert variant="warning" title={title} description={description} showIcon data-testid={testId} />
|
|
47
|
+
<Button
|
|
48
|
+
variant="outline"
|
|
49
|
+
color="neutral"
|
|
50
|
+
size="md"
|
|
51
|
+
onClick={onCheckAgain}
|
|
52
|
+
dataTestId={actionTestId}
|
|
53
|
+
>
|
|
54
|
+
{copy.checkAgainAction}
|
|
55
|
+
</Button>
|
|
56
|
+
</Box>
|
|
57
|
+
);
|
|
58
|
+
}
|