@12-apps/payments-frontend 3.20.0 → 3.21.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/activation/screens/activation-step.tsx +1 -0
- package/src/activation/screens/card-verification.tsx +8 -1
- package/src/activation/screens/copy-en-US.ts +1 -1
- package/src/activation/screens/copy-pt-BR.ts +5 -3
- package/src/activation/screens/copy.ts +8 -1
- 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.
|
|
3
|
+
"version": "3.21.0",
|
|
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": {
|
|
@@ -35,10 +35,13 @@ export type CardSurface = ComponentType<{ children: ReactNode }>;
|
|
|
35
35
|
function ChargeForm({
|
|
36
36
|
verification,
|
|
37
37
|
amountLabel,
|
|
38
|
+
displayName,
|
|
38
39
|
validateTaxId,
|
|
39
40
|
}: {
|
|
40
41
|
verification: ActivationCharge;
|
|
41
42
|
amountLabel: string | null;
|
|
43
|
+
/** The provider being activated, as the owner knows it. */
|
|
44
|
+
displayName: string;
|
|
42
45
|
validateTaxId: (value: string) => string | undefined;
|
|
43
46
|
}): JSX.Element {
|
|
44
47
|
const { taxId, actions } = useActivationCopy();
|
|
@@ -65,7 +68,7 @@ function ChargeForm({
|
|
|
65
68
|
placeholder={taxId.placeholder}
|
|
66
69
|
value={formatCpf(cpf)}
|
|
67
70
|
error={Boolean(cpfError)}
|
|
68
|
-
helperText={cpfError ?? taxId.hint}
|
|
71
|
+
helperText={cpfError ?? taxId.hint(displayName)}
|
|
69
72
|
onChange={(event) => setCpf(onlyDigits(event.target.value))}
|
|
70
73
|
onBlur={() => validateTaxId(cpf)}
|
|
71
74
|
data-testid="verify-charge-cpf"
|
|
@@ -110,6 +113,7 @@ function CardOutcome({
|
|
|
110
113
|
export function CardVerification({
|
|
111
114
|
verifyChargeUrl,
|
|
112
115
|
provider,
|
|
116
|
+
displayName,
|
|
113
117
|
ownerEmail,
|
|
114
118
|
onVerified,
|
|
115
119
|
CardSurface: Surface,
|
|
@@ -118,6 +122,8 @@ export function CardVerification({
|
|
|
118
122
|
}: {
|
|
119
123
|
verifyChargeUrl: string;
|
|
120
124
|
provider: string;
|
|
125
|
+
/** The name the OWNER reads; `provider` beside it is the machine key. */
|
|
126
|
+
displayName: string;
|
|
121
127
|
/** Who is paying — the signed-in owner. The host reads its own session. */
|
|
122
128
|
ownerEmail: string;
|
|
123
129
|
onVerified: () => void;
|
|
@@ -173,6 +179,7 @@ export function CardVerification({
|
|
|
173
179
|
<ChargeForm
|
|
174
180
|
verification={verification}
|
|
175
181
|
amountLabel={amountLabel}
|
|
182
|
+
displayName={displayName}
|
|
176
183
|
validateTaxId={validateTaxId}
|
|
177
184
|
/>
|
|
178
185
|
</Surface>
|
|
@@ -88,7 +88,7 @@ export const EN_US_ACTIVATION_STEP_COPY: ActivationStepCopy = {
|
|
|
88
88
|
},
|
|
89
89
|
taxId: {
|
|
90
90
|
label: "Cardholder's CPF",
|
|
91
|
-
hint:
|
|
91
|
+
hint: (displayName) => `${displayName} requires it on every card charge.`,
|
|
92
92
|
placeholder: '000.000.000-00',
|
|
93
93
|
},
|
|
94
94
|
charge: {
|
|
@@ -25,8 +25,10 @@ import type { ActivationStepCopy } from './copy';
|
|
|
25
25
|
* sent looking for "Integrated Checkout" is looking for a screen that does not
|
|
26
26
|
* exist. **CPF** and its `000.000.000-00` mask are a Brazilian document and a
|
|
27
27
|
* FORMAT — a translated mask shows a shape the field refuses. **PagBank** and
|
|
28
|
-
* **InfinitePay** are names
|
|
29
|
-
*
|
|
28
|
+
* **InfinitePay** are names — PagBank now only as the console label above, since
|
|
29
|
+
* the tax-id hint takes the provider as an ARGUMENT rather than naming one
|
|
30
|
+
* (FUT-675). `{provider}` is this package's own placeholder, substituted after
|
|
31
|
+
* the sentence is chosen.
|
|
30
32
|
*/
|
|
31
33
|
export const PT_BR_ACTIVATION_STEP_COPY: ActivationStepCopy = {
|
|
32
34
|
intro: {
|
|
@@ -104,7 +106,7 @@ export const PT_BR_ACTIVATION_STEP_COPY: ActivationStepCopy = {
|
|
|
104
106
|
},
|
|
105
107
|
taxId: {
|
|
106
108
|
label: 'CPF do titular',
|
|
107
|
-
hint:
|
|
109
|
+
hint: (displayName) => `A ${displayName} exige o CPF em qualquer cobrança no cartão.`,
|
|
108
110
|
placeholder: '000.000.000-00',
|
|
109
111
|
},
|
|
110
112
|
charge: {
|
|
@@ -126,7 +126,14 @@ export interface ActivationOutcomeCopy {
|
|
|
126
126
|
/** The CPF field the card flow asks for beside the card. */
|
|
127
127
|
export interface ActivationTaxIdCopy {
|
|
128
128
|
readonly label: string;
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* WHO is asking for the document, by name. A parameter rather than a
|
|
131
|
+
* sentence, for the reason the setup guide's `credentials` takes one: the
|
|
132
|
+
* provider is a fact about the connection being activated, not about the
|
|
133
|
+
* language. Both packs had spelled one provider's name into the string, so a
|
|
134
|
+
* Stone activation told the owner PagBank required their CPF (FUT-675).
|
|
135
|
+
*/
|
|
136
|
+
readonly hint: (displayName: string) => string;
|
|
130
137
|
readonly placeholder: string;
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -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',
|