@12-apps/payments-frontend 3.5.0 → 3.7.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 +4 -4
- package/src/components/checkout/checkout-flow.tsx +56 -23
- package/src/components/checkout/checkout-steps.tsx +60 -31
- package/src/components/checkout/method-capability.ts +27 -0
- package/src/components/checkout/method-choice.ts +64 -0
- package/src/components/checkout/payment-status.tsx +50 -62
- package/src/components/checkout/payments-unavailable.tsx +14 -5
- package/src/components/checkout/providers/capability-default.tsx +1 -35
- package/src/components/checkout/providers/hands-over.ts +43 -0
- package/src/components/checkout/providers/hosted-link.tsx +113 -12
- package/src/components/checkout/providers/registry.ts +30 -0
- package/src/components/checkout/providers/types.ts +19 -1
- package/src/components/checkout/pt-BR.ts +74 -0
- package/src/components/checkout/types.ts +11 -0
- package/src/components/checkout/view-copy.ts +94 -0
- package/src/flows/copy.ts +27 -3
- package/src/flows/create-payment-flows.tsx +1 -0
- package/src/flows/screens-buyer.tsx +6 -4
- package/src/flows/screens-pay.tsx +1 -0
- package/src/index.ts +14 -0
|
@@ -4,6 +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
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The last screen of checkout.
|
|
@@ -28,67 +29,38 @@ import { useCheckoutComponents } from "./ui";
|
|
|
28
29
|
* screen says and how it is arranged has changed.
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
35
41
|
icon: JSX.Element;
|
|
36
42
|
/** Semantic theme token — never a raw colour. */
|
|
37
43
|
tone: "success" | "danger" | "warning" | "neutral";
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
const
|
|
41
|
-
PAID: {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
tone: "success",
|
|
46
|
-
},
|
|
47
|
-
AWAITING_PAYMENT: {
|
|
48
|
-
heading: "Confirmando seu pagamento",
|
|
49
|
-
support: "Isso costuma levar alguns segundos. Pode deixar esta tela aberta.",
|
|
50
|
-
icon: <ScheduleIcon fontSize="large" />,
|
|
51
|
-
tone: "neutral",
|
|
52
|
-
},
|
|
53
|
-
FAILED: {
|
|
54
|
-
heading: "Pagamento não concluído",
|
|
55
|
-
// Said plainly and first: the fear on this screen is having been charged
|
|
56
|
-
// for an order that failed.
|
|
57
|
-
support: "Nenhum valor foi cobrado. Você pode tentar novamente.",
|
|
58
|
-
icon: <ErrorOutlineIcon fontSize="large" />,
|
|
59
|
-
tone: "danger",
|
|
60
|
-
},
|
|
61
|
-
EXPIRED: {
|
|
62
|
-
heading: "O código expirou",
|
|
63
|
-
support: "Nenhum valor foi cobrado. Gere um novo código para continuar.",
|
|
64
|
-
icon: <ScheduleIcon fontSize="large" />,
|
|
65
|
-
tone: "warning",
|
|
66
|
-
},
|
|
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" },
|
|
67
51
|
};
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
*
|
|
78
|
-
* So it leads with what is true and unglamorous — we have not heard yet — and
|
|
79
|
-
* spends its remaining words on the one instruction that matters, which is not
|
|
80
|
-
* to pay again.
|
|
81
|
-
*/
|
|
82
|
-
const AWAITING_TIMED_OUT: Outcome = {
|
|
83
|
-
heading: "Ainda não recebemos a confirmação",
|
|
84
|
-
support:
|
|
85
|
-
"Se você já pagou, o pedido é confirmado assim que a operadora avisar — " +
|
|
86
|
-
"não pague de novo. Você pode fechar esta tela.",
|
|
87
|
-
icon: <ScheduleIcon fontSize="large" />,
|
|
88
|
-
tone: "warning",
|
|
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",
|
|
89
61
|
};
|
|
90
62
|
|
|
91
|
-
const TONE_COLOR: Record<
|
|
63
|
+
const TONE_COLOR: Record<OutcomeVisual["tone"], string> = {
|
|
92
64
|
success: "success.main",
|
|
93
65
|
danger: "error.main",
|
|
94
66
|
warning: "warning.main",
|
|
@@ -109,14 +81,22 @@ function orderReference(orderId: string): string {
|
|
|
109
81
|
|
|
110
82
|
/** The headline block: icon, outcome, and one supporting line. */
|
|
111
83
|
function OutcomeHero({
|
|
84
|
+
copy,
|
|
112
85
|
status,
|
|
113
86
|
timedOut = false,
|
|
114
87
|
}: {
|
|
88
|
+
copy: PaymentStatusCopy;
|
|
115
89
|
status: OrderStatus;
|
|
116
90
|
timedOut?: boolean;
|
|
117
91
|
}): JSX.Element {
|
|
118
92
|
const { Text } = useCheckoutComponents();
|
|
119
|
-
const
|
|
93
|
+
const timedOutWait = timedOut && status === "AWAITING_PAYMENT";
|
|
94
|
+
// The timed-out wait keeps AWAITING's neutral clock icon but WARNING's tone:
|
|
95
|
+
// the order is not resolved, and calm-but-alert is the visual for that.
|
|
96
|
+
const visual = timedOutWait
|
|
97
|
+
? { icon: OUTCOME_VISUAL.AWAITING_PAYMENT.icon, tone: "warning" as const }
|
|
98
|
+
: OUTCOME_VISUAL[status];
|
|
99
|
+
const outcome = timedOutWait ? copy.awaitingTimedOut : copy[OUTCOME_COPY_KEY[status]];
|
|
120
100
|
return (
|
|
121
101
|
<Box
|
|
122
102
|
// `payment-paid` is load-bearing for the storefront journeys — it is how
|
|
@@ -133,7 +113,7 @@ function OutcomeHero({
|
|
|
133
113
|
}
|
|
134
114
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, textAlign: "center" }}
|
|
135
115
|
>
|
|
136
|
-
<Box sx={{ color: TONE_COLOR[
|
|
116
|
+
<Box sx={{ color: TONE_COLOR[visual.tone], display: "flex" }}>{visual.icon}</Box>
|
|
137
117
|
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
138
118
|
{outcome.heading}
|
|
139
119
|
</Text>
|
|
@@ -165,10 +145,12 @@ function Fact({ label, value, testId }: { label: string; value: string; testId?:
|
|
|
165
145
|
* outcome these facts are either untrue or not yet knowable.
|
|
166
146
|
*/
|
|
167
147
|
function PaidFacts({
|
|
148
|
+
copy,
|
|
168
149
|
totalLabel,
|
|
169
150
|
orderId,
|
|
170
151
|
buyerEmail,
|
|
171
152
|
}: {
|
|
153
|
+
copy: PaymentStatusCopy;
|
|
172
154
|
totalLabel: string;
|
|
173
155
|
orderId?: string;
|
|
174
156
|
buyerEmail?: string;
|
|
@@ -185,22 +167,24 @@ function PaidFacts({
|
|
|
185
167
|
bgcolor: "action.hover",
|
|
186
168
|
}}
|
|
187
169
|
>
|
|
188
|
-
<Fact label=
|
|
170
|
+
<Fact label={copy.amountLabel} value={totalLabel} testId="payment-amount" />
|
|
189
171
|
{orderId ? (
|
|
190
|
-
<Fact label=
|
|
172
|
+
<Fact label={copy.referenceLabel} value={`#${orderReference(orderId)}`} testId="payment-reference" />
|
|
191
173
|
) : null}
|
|
192
|
-
{buyerEmail ? <Fact label=
|
|
174
|
+
{buyerEmail ? <Fact label={copy.receiptEmailLabel} value={buyerEmail} /> : null}
|
|
193
175
|
</Box>
|
|
194
176
|
);
|
|
195
177
|
}
|
|
196
178
|
|
|
197
179
|
/** The next-action row: retry / regenerate for failures, always back-to-menu. */
|
|
198
180
|
function StatusActions({
|
|
181
|
+
copy,
|
|
199
182
|
status,
|
|
200
183
|
onRetry,
|
|
201
184
|
onRegenerate,
|
|
202
185
|
onBackToMenu,
|
|
203
186
|
}: {
|
|
187
|
+
copy: PaymentStatusCopy;
|
|
204
188
|
status: OrderStatus;
|
|
205
189
|
onRetry?: () => void;
|
|
206
190
|
onRegenerate?: () => void;
|
|
@@ -211,12 +195,12 @@ function StatusActions({
|
|
|
211
195
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
212
196
|
{status === "FAILED" && onRetry ? (
|
|
213
197
|
<Button variant="solid" color="primary" size="lg" onClick={onRetry} dataTestId="payment-retry">
|
|
214
|
-
|
|
198
|
+
{copy.retryAction}
|
|
215
199
|
</Button>
|
|
216
200
|
) : null}
|
|
217
201
|
{status === "EXPIRED" && onRegenerate ? (
|
|
218
202
|
<Button variant="solid" color="primary" size="lg" onClick={onRegenerate} dataTestId="payment-regenerate">
|
|
219
|
-
|
|
203
|
+
{copy.regenerateAction}
|
|
220
204
|
</Button>
|
|
221
205
|
) : null}
|
|
222
206
|
<Button
|
|
@@ -228,13 +212,14 @@ function StatusActions({
|
|
|
228
212
|
onClick={onBackToMenu}
|
|
229
213
|
dataTestId="payment-back-to-menu"
|
|
230
214
|
>
|
|
231
|
-
|
|
215
|
+
{copy.backAction}
|
|
232
216
|
</Button>
|
|
233
217
|
</Box>
|
|
234
218
|
);
|
|
235
219
|
}
|
|
236
220
|
|
|
237
221
|
export function PaymentStatus({
|
|
222
|
+
copy,
|
|
238
223
|
status,
|
|
239
224
|
totalLabel,
|
|
240
225
|
orderId,
|
|
@@ -245,6 +230,8 @@ export function PaymentStatus({
|
|
|
245
230
|
paidExtra,
|
|
246
231
|
awaitingTimedOut = false,
|
|
247
232
|
}: {
|
|
233
|
+
/** Every sentence and label this screen renders — the HOST's words. */
|
|
234
|
+
copy: PaymentStatusCopy;
|
|
248
235
|
status: OrderStatus | null;
|
|
249
236
|
totalLabel: string;
|
|
250
237
|
/** The created order, when there is one — absent before the charge is raised. */
|
|
@@ -278,10 +265,10 @@ export function PaymentStatus({
|
|
|
278
265
|
data-timed-out={awaitingTimedOut ? "true" : undefined}
|
|
279
266
|
sx={{ display: "flex", flexDirection: "column", gap: 3, alignItems: "stretch", py: 2 }}
|
|
280
267
|
>
|
|
281
|
-
<OutcomeHero status={effective} timedOut={awaitingTimedOut} />
|
|
268
|
+
<OutcomeHero copy={copy} status={effective} timedOut={awaitingTimedOut} />
|
|
282
269
|
|
|
283
270
|
{effective === "PAID" ? (
|
|
284
|
-
<PaidFacts totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
271
|
+
<PaidFacts copy={copy} totalLabel={totalLabel} orderId={orderId} buyerEmail={buyerEmail} />
|
|
285
272
|
) : null}
|
|
286
273
|
|
|
287
274
|
{effective === "PAID" ? paidExtra : null}
|
|
@@ -291,6 +278,7 @@ export function PaymentStatus({
|
|
|
291
278
|
) : null}
|
|
292
279
|
|
|
293
280
|
<StatusActions
|
|
281
|
+
copy={copy}
|
|
294
282
|
status={effective}
|
|
295
283
|
onRetry={onRetry}
|
|
296
284
|
onRegenerate={onRegenerate}
|
|
@@ -23,8 +23,16 @@ import { Box } from "@mui/material";
|
|
|
23
23
|
import type { JSX } from "react";
|
|
24
24
|
|
|
25
25
|
import { useCheckoutComponents } from "./ui";
|
|
26
|
+
import type { PaymentsUnavailableCopy } from "./view-copy";
|
|
26
27
|
|
|
27
28
|
interface PaymentsUnavailableProps {
|
|
29
|
+
/**
|
|
30
|
+
* Every sentence this screen can say — the HOST's words (see
|
|
31
|
+
* `./view-copy`). Until 6.0.0 the two remedies and the button were compiled
|
|
32
|
+
* in, in one application's Portuguese, and a host that dutifully passed the
|
|
33
|
+
* flows copy port still got them.
|
|
34
|
+
*/
|
|
35
|
+
copy: PaymentsUnavailableCopy;
|
|
28
36
|
/**
|
|
29
37
|
* Whether THIS shopper can settle with a waiter — mesas on AND a mesa of
|
|
30
38
|
* their own. Decides which remedy is offered.
|
|
@@ -37,6 +45,7 @@ interface PaymentsUnavailableProps {
|
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export function PaymentsUnavailable({
|
|
48
|
+
copy,
|
|
40
49
|
waiterAvailable,
|
|
41
50
|
onCallWaiter,
|
|
42
51
|
calling = false,
|
|
@@ -47,8 +56,8 @@ export function PaymentsUnavailable({
|
|
|
47
56
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-payments-disabled">
|
|
48
57
|
<Alert
|
|
49
58
|
variant="info"
|
|
50
|
-
title=
|
|
51
|
-
description=
|
|
59
|
+
title={copy.title}
|
|
60
|
+
description={copy.body}
|
|
52
61
|
showIcon
|
|
53
62
|
/>
|
|
54
63
|
</Box>
|
|
@@ -59,8 +68,8 @@ export function PaymentsUnavailable({
|
|
|
59
68
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }} data-testid="checkout-call-waiter">
|
|
60
69
|
<Alert
|
|
61
70
|
variant="info"
|
|
62
|
-
title=
|
|
63
|
-
description=
|
|
71
|
+
title={copy.remedyTitle}
|
|
72
|
+
description={copy.remedyBody}
|
|
64
73
|
showIcon
|
|
65
74
|
/>
|
|
66
75
|
{onCallWaiter ? (
|
|
@@ -71,7 +80,7 @@ export function PaymentsUnavailable({
|
|
|
71
80
|
onClick={onCallWaiter}
|
|
72
81
|
dataTestId="checkout-call-waiter-button"
|
|
73
82
|
>
|
|
74
|
-
{calling ?
|
|
83
|
+
{calling ? copy.callingAction : copy.callAction}
|
|
75
84
|
</Button>
|
|
76
85
|
) : null}
|
|
77
86
|
</Box>
|
|
@@ -19,45 +19,11 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { JSX } from "react";
|
|
21
21
|
|
|
22
|
-
import
|
|
23
|
-
|
|
22
|
+
import { handsBuyerOver } from "./hands-over";
|
|
24
23
|
import { HostedLinkScreen } from "./hosted-link";
|
|
25
24
|
import { PixAndCardScreen } from "./pix-and-card";
|
|
26
25
|
import type { ProviderCheckoutScreenProps } from "./types";
|
|
27
26
|
|
|
28
|
-
/** Schemes that give the BROWSER a card form of its own. */
|
|
29
|
-
const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Whether this store hands the buyer over instead of collecting here — the
|
|
33
|
-
* frontend twin of the server's `usesHostedCheckout`, deliberately written to
|
|
34
|
-
* the same three rules so the pane and the walk cannot disagree.
|
|
35
|
-
*
|
|
36
|
-
* Note this is NOT `!cardPathAvailable(config)`. That helper answers a
|
|
37
|
-
* different question — "is a card offerable at all" — and it answers TRUE for
|
|
38
|
-
* a hand-off store, because typing the card on the provider's page is still a
|
|
39
|
-
* card path. Inverting it therefore sends the hosted store to the on-page
|
|
40
|
-
* screen and the on-page store to the hand-off, which is exactly backwards.
|
|
41
|
-
*
|
|
42
|
-
* The rules, in order:
|
|
43
|
-
* - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
|
|
44
|
-
* says how the browser turns a PAN into an instrument, and a PIX charge
|
|
45
|
-
* has no instrument to mint. A store with no card-capable entry is not
|
|
46
|
-
* hosted — this is the FUT-747 correction, and getting it wrong routed the
|
|
47
|
-
* simplest store there is (one PIX-only provider honestly declaring
|
|
48
|
-
* `NONE`) into a hand-off it had no link for.
|
|
49
|
-
* - Hosted only when NOBODY who takes a card takes it here.
|
|
50
|
-
* - No chain served (an older host, a still-loading config) ⇒ not hosted,
|
|
51
|
-
* which is what this checkout did before there was a chain to read.
|
|
52
|
-
*/
|
|
53
|
-
function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
|
|
54
|
-
const chain = config?.chain;
|
|
55
|
-
if (!chain || chain.length === 0) return false;
|
|
56
|
-
const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
|
|
57
|
-
if (cardCapable.length === 0) return false;
|
|
58
|
-
return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
27
|
export function CapabilityDefaultScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
|
|
62
28
|
return handsBuyerOver(props.config) ? (
|
|
63
29
|
<HostedLinkScreen {...props} />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a store finishes checkout on the PROVIDER's own page.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because two callers need the same answer and must never be
|
|
5
|
+
* able to disagree about it: {@link CapabilityDefaultScreen}, which picks the
|
|
6
|
+
* pane for a provider that declared no screen, and the shell's picker gate,
|
|
7
|
+
* which decides whether the buyer is asked PIX-or-card here at all. It lived
|
|
8
|
+
* inside `capability-default.tsx` while there was one caller.
|
|
9
|
+
*/
|
|
10
|
+
import type { CheckoutProviderConfig } from "../types";
|
|
11
|
+
|
|
12
|
+
/** Schemes that give the BROWSER a card form of its own. */
|
|
13
|
+
const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether this store hands the buyer over instead of collecting here — the
|
|
17
|
+
* frontend twin of the server's `usesHostedCheckout`, deliberately written to
|
|
18
|
+
* the same three rules so the pane and the walk cannot disagree.
|
|
19
|
+
*
|
|
20
|
+
* Note this is NOT `!cardPathAvailable(config)`. That helper answers a
|
|
21
|
+
* different question — "is a card offerable at all" — and it answers TRUE for
|
|
22
|
+
* a hand-off store, because typing the card on the provider's page is still a
|
|
23
|
+
* card path. Inverting it therefore sends the hosted store to the on-page
|
|
24
|
+
* screen and the on-page store to the hand-off, which is exactly backwards.
|
|
25
|
+
*
|
|
26
|
+
* The rules, in order:
|
|
27
|
+
* - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
|
|
28
|
+
* says how the browser turns a PAN into an instrument, and a PIX charge
|
|
29
|
+
* has no instrument to mint. A store with no card-capable entry is not
|
|
30
|
+
* hosted — this is the FUT-747 correction, and getting it wrong routed the
|
|
31
|
+
* simplest store there is (one PIX-only provider honestly declaring
|
|
32
|
+
* `NONE`) into a hand-off it had no link for.
|
|
33
|
+
* - Hosted only when NOBODY who takes a card takes it here.
|
|
34
|
+
* - No chain served (an older host, a still-loading config) ⇒ not hosted,
|
|
35
|
+
* which is what this checkout did before there was a chain to read.
|
|
36
|
+
*/
|
|
37
|
+
export function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
|
|
38
|
+
const chain = config?.chain;
|
|
39
|
+
if (!chain || chain.length === 0) return false;
|
|
40
|
+
const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
|
|
41
|
+
if (cardCapable.length === 0) return false;
|
|
42
|
+
return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
|
|
43
|
+
}
|
|
@@ -15,26 +15,112 @@
|
|
|
15
15
|
* is leaving this page and parking the payable for the return leg matters more
|
|
16
16
|
* than painting a pane they will not see. `hosted-return.ts` picks it back up.
|
|
17
17
|
*
|
|
18
|
-
* So the whole job here is the moment BEFORE that: the buyer
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* So the whole job here is the moment BEFORE that: telling the buyer where
|
|
19
|
+
* they are about to go, and giving them the one action that takes them.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the ACTION lives here and not in the shell's picker
|
|
22
|
+
*
|
|
23
|
+
* The shell hides its PIX/card picker for this screen (`methodChosenAtProvider`
|
|
24
|
+
* in `./registry.ts`), so the screen owns the affordance that starts the
|
|
25
|
+
* charge. That is the point of the whole arrangement: the method question is
|
|
26
|
+
* binding on the provider's page, not on ours — every answer mints the same
|
|
27
|
+
* link — so asking it twice tells the buyer their first answer was thrown
|
|
28
|
+
* away. One button that says where it leads is the honest version of that
|
|
29
|
+
* screen, and it also removes the moment where the pane rendered `null` under
|
|
30
|
+
* a picker, which reads as a checkout that has stalled.
|
|
23
31
|
*/
|
|
24
32
|
import { Box } from "@mui/material";
|
|
25
33
|
import type { JSX } from "react";
|
|
26
34
|
|
|
35
|
+
import { offeredMethods } from "../method-capability";
|
|
36
|
+
import type { CheckoutProviderConfig } from "../types";
|
|
27
37
|
import { useCheckoutComponents } from "../ui";
|
|
28
38
|
|
|
29
39
|
import type { ProviderCheckoutScreenProps } from "./types";
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* How the store's own page is named to the buyer — "à página segura da
|
|
43
|
+
* InfinitePay", or the provider-neutral phrasing when no name was published.
|
|
44
|
+
*
|
|
45
|
+
* The neutral form is not a lesser fallback to be tidied away later: a host one
|
|
46
|
+
* release behind serves no `displayName`, and the buyer of that store gets a
|
|
47
|
+
* true sentence rather than our internal id ("infinitepay") dressed up as a
|
|
48
|
+
* brand.
|
|
49
|
+
*/
|
|
50
|
+
function destinationLabel(config: CheckoutProviderConfig | null): string {
|
|
51
|
+
const name = config?.chain?.[0]?.displayName?.trim();
|
|
52
|
+
return name ? `à página de pagamento da ${name}` : "à página de pagamento segura do provedor";
|
|
53
|
+
}
|
|
33
54
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
/**
|
|
56
|
+
* What the buyer will be asked to choose between once they get there, so the
|
|
57
|
+
* sentence promises exactly what the provider's page offers.
|
|
58
|
+
*
|
|
59
|
+
* Read from the same declaration the picker used to render, which is what
|
|
60
|
+
* keeps this honest for a provider that takes only one of the two: a hosted
|
|
61
|
+
* PIX-only store must not promise a card.
|
|
62
|
+
*/
|
|
63
|
+
function methodsPhrase(config: CheckoutProviderConfig | null): string | null {
|
|
64
|
+
const offered = offeredMethods(config);
|
|
65
|
+
const pix = offered === null || offered.includes("PIX");
|
|
66
|
+
const card = offered === null || offered.includes("CARD");
|
|
67
|
+
if (pix && card) return "PIX ou cartão";
|
|
68
|
+
if (pix) return "PIX";
|
|
69
|
+
if (card) return "cartão";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The full "where you are going and what happens there" sentence. */
|
|
74
|
+
function handoffMessage(config: CheckoutProviderConfig | null): string {
|
|
75
|
+
const methods = methodsPhrase(config);
|
|
76
|
+
const choice = methods ? `, onde você escolhe pagar com ${methods}` : "";
|
|
77
|
+
return `Você será levado ${destinationLabel(config)}${choice}.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The invitation: what happens next, and the one button that starts it. */
|
|
81
|
+
function HandOffInvite({
|
|
82
|
+
config,
|
|
83
|
+
onStart,
|
|
84
|
+
}: {
|
|
85
|
+
config: CheckoutProviderConfig | null;
|
|
86
|
+
onStart: () => void;
|
|
87
|
+
}): JSX.Element {
|
|
88
|
+
const { Button, Text } = useCheckoutComponents();
|
|
89
|
+
return (
|
|
90
|
+
<Box
|
|
91
|
+
data-testid="checkout-handoff-invite"
|
|
92
|
+
sx={{ display: "flex", flexDirection: "column", gap: 2 }}
|
|
93
|
+
>
|
|
94
|
+
<Text variant="body" size="md" as="p">
|
|
95
|
+
{handoffMessage(config)}
|
|
96
|
+
</Text>
|
|
97
|
+
<Text variant="caption" size="sm" as="p" color="secondary">
|
|
98
|
+
Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
|
|
99
|
+
</Text>
|
|
100
|
+
<Button
|
|
101
|
+
variant="solid"
|
|
102
|
+
color="primary"
|
|
103
|
+
size="lg"
|
|
104
|
+
fullWidth
|
|
105
|
+
onClick={onStart}
|
|
106
|
+
dataTestId="checkout-handoff-start"
|
|
107
|
+
>
|
|
108
|
+
Seguir para o pagamento
|
|
109
|
+
</Button>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
37
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The moment after the button: the charge is being raised, then we navigate.
|
|
116
|
+
*
|
|
117
|
+
* Says the SAME sentence the invite did, from the same helper. It used to word
|
|
118
|
+
* the destination differently — "a página segura do provedor" against the
|
|
119
|
+
* invite's named one — so the screen appeared to change its mind about where
|
|
120
|
+
* the buyer was going at the exact moment they committed to going there.
|
|
121
|
+
*/
|
|
122
|
+
function HandOffPending({ config }: { config: CheckoutProviderConfig | null }): JSX.Element {
|
|
123
|
+
const { Text, LoadingState } = useCheckoutComponents();
|
|
38
124
|
return (
|
|
39
125
|
<Box
|
|
40
126
|
data-testid="checkout-handoff-pending"
|
|
@@ -42,11 +128,26 @@ export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.E
|
|
|
42
128
|
>
|
|
43
129
|
<LoadingState variant="spinner" message="Preparando o pagamento" size="md" />
|
|
44
130
|
<Text variant="body" size="md" as="p">
|
|
45
|
-
|
|
131
|
+
{handoffMessage(config)}
|
|
46
132
|
</Text>
|
|
47
133
|
<Text variant="caption" size="sm" as="p" color="secondary">
|
|
48
|
-
Assim que
|
|
134
|
+
Assim que o pagamento for concluído, você volta para cá e nós confirmamos o pedido.
|
|
49
135
|
</Text>
|
|
50
136
|
</Box>
|
|
51
137
|
);
|
|
52
138
|
}
|
|
139
|
+
|
|
140
|
+
export function HostedLinkScreen({
|
|
141
|
+
method,
|
|
142
|
+
config,
|
|
143
|
+
onStart,
|
|
144
|
+
}: ProviderCheckoutScreenProps): JSX.Element | null {
|
|
145
|
+
// A method is only ever set here once the buyer has committed — either by
|
|
146
|
+
// pressing the CTA below, or (on a host whose shell still renders a picker)
|
|
147
|
+
// by choosing a tile. Both mean the same thing: the hand-off is underway.
|
|
148
|
+
if (method) return <HandOffPending config={config} />;
|
|
149
|
+
// No method and no CTA to offer ⇒ the shell is still showing its picker, and
|
|
150
|
+
// the pane stays out of the way exactly as every other screen does.
|
|
151
|
+
if (!onStart) return null;
|
|
152
|
+
return <HandOffInvite config={config} onStart={onStart} />;
|
|
153
|
+
}
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
* to an empty pane.
|
|
27
27
|
*/
|
|
28
28
|
import { CapabilityDefaultScreen } from "./capability-default";
|
|
29
|
+
import { handsBuyerOver } from "./hands-over";
|
|
29
30
|
import { HostedLinkScreen } from "./hosted-link";
|
|
30
31
|
import { PixAndCardScreen } from "./pix-and-card";
|
|
32
|
+
import type { CheckoutProviderConfig } from "../types";
|
|
31
33
|
import type { ProviderCheckoutScreen } from "./types";
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -76,3 +78,31 @@ export function resolveCheckoutScreen(
|
|
|
76
78
|
): ProviderCheckoutScreen {
|
|
77
79
|
return screenFor(chainHeadScreen) ?? CapabilityDefaultScreen;
|
|
78
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Does the buyer choose PIX-or-card on the PROVIDER's page rather than on ours?
|
|
84
|
+
*
|
|
85
|
+
* The shell asks this before rendering its method picker, and hides the picker
|
|
86
|
+
* when the answer is yes (FUT-596 follow-up). A hand-off store asks the
|
|
87
|
+
* question twice otherwise: once here, where the answer changes nothing —
|
|
88
|
+
* every method mints the same checkout link — and again on the provider's own
|
|
89
|
+
* page, where it is finally binding. The first ask is not merely redundant, it
|
|
90
|
+
* is misleading: the buyer who picked PIX here has picked nothing, and finding
|
|
91
|
+
* the same two options waiting for them on another site reads as a checkout
|
|
92
|
+
* that lost their answer.
|
|
93
|
+
*
|
|
94
|
+
* Resolved in the SAME order as {@link resolveCheckoutScreen} — a declared id
|
|
95
|
+
* wins, and an undeclared or unknown one falls back to the capability read —
|
|
96
|
+
* so the picker and the pane can never disagree about which flow this is. That
|
|
97
|
+
* is why the test is an identity check against the hand-off screen rather than
|
|
98
|
+
* a second table of ids: a table would be the copy that drifts, and it would
|
|
99
|
+
* drift the moment a new hand-off adapter declared its own id.
|
|
100
|
+
*/
|
|
101
|
+
export function methodChosenAtProvider(
|
|
102
|
+
chainHeadScreen: string | null | undefined,
|
|
103
|
+
config: CheckoutProviderConfig | null,
|
|
104
|
+
): boolean {
|
|
105
|
+
const declared = screenFor(chainHeadScreen);
|
|
106
|
+
if (declared) return declared === HostedLinkScreen;
|
|
107
|
+
return handsBuyerOver(config);
|
|
108
|
+
}
|
|
@@ -46,8 +46,26 @@ export interface ProviderCheckoutScreenProps {
|
|
|
46
46
|
* those readings call the helpers themselves.
|
|
47
47
|
*/
|
|
48
48
|
config: CheckoutProviderConfig | null;
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What the shell currently has selected; `null` before a choice.
|
|
51
|
+
*
|
|
52
|
+
* `null` is also the resting state of a screen that OWNS the choice (see
|
|
53
|
+
* {@link ProviderCheckoutScreenProps.onStart}): nothing is selected until
|
|
54
|
+
* the buyer presses that screen's own CTA.
|
|
55
|
+
*/
|
|
50
56
|
method: PaymentMethod | null;
|
|
57
|
+
/**
|
|
58
|
+
* Present ⇒ this screen owns the "how do I start paying" affordance, because
|
|
59
|
+
* the shell has hidden its method picker for it (FUT-596 follow-up). Calling
|
|
60
|
+
* it commits the buyer to the store's hand-off method and raises the charge,
|
|
61
|
+
* exactly as choosing a tile in the picker does.
|
|
62
|
+
*
|
|
63
|
+
* Absent for every screen where the picker is still on the page — those
|
|
64
|
+
* screens must not grow a second way to start the same charge.
|
|
65
|
+
*/
|
|
66
|
+
onStart?: () => void;
|
|
67
|
+
/** A charge is being raised right now — the shell's own busy flag. */
|
|
68
|
+
creating?: boolean;
|
|
51
69
|
/** Scopes the saved-card list to the store being paid. */
|
|
52
70
|
tenantSlug?: string;
|
|
53
71
|
/** The shell's polling cadence, passed through so tests can shorten it. */
|