@12-apps/payments-frontend 3.5.0 → 3.6.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/checkout-steps.tsx +39 -19
- package/src/components/checkout/method-capability.ts +27 -0
- package/src/components/checkout/method-choice.ts +64 -0
- 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/types.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"storybook:build": "storybook build"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@12-apps/payments-backend": "^4.
|
|
20
|
+
"@12-apps/payments-backend": "^4.14.0",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -3,12 +3,7 @@ import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
|
3
3
|
|
|
4
4
|
import { BuyerInfoForm } from "./buyer-info-form";
|
|
5
5
|
import { LockOutlinedIcon } from "./icons";
|
|
6
|
-
import {
|
|
7
|
-
cardPathAvailable,
|
|
8
|
-
offeredMethods,
|
|
9
|
-
selectableMethods,
|
|
10
|
-
usePreselectSoleMethod,
|
|
11
|
-
} from "./method-capability";
|
|
6
|
+
import { useMethodChoice } from "./method-choice";
|
|
12
7
|
import { MethodPicker } from "./method-picker";
|
|
13
8
|
import { PaymentErrorPanel } from "./payment-error-panel";
|
|
14
9
|
import { PayerSummary } from "./payer-summary";
|
|
@@ -42,8 +37,8 @@ function useAutoRaiseOrder(
|
|
|
42
37
|
requestedFor.current = null;
|
|
43
38
|
return;
|
|
44
39
|
}
|
|
45
|
-
// No method chosen yet ⇒ show only the picker
|
|
46
|
-
//
|
|
40
|
+
// No method chosen yet ⇒ show only the picker (or, for a hand-off store,
|
|
41
|
+
// its "Seguir para o pagamento"); raise the order once the buyer commits.
|
|
47
42
|
if (!method || creating || createError || requestedFor.current === method) return;
|
|
48
43
|
requestedFor.current = method;
|
|
49
44
|
onGenerate(method);
|
|
@@ -70,6 +65,8 @@ function PaymentBody({
|
|
|
70
65
|
method,
|
|
71
66
|
tenantSlug,
|
|
72
67
|
onResolved,
|
|
68
|
+
onStart,
|
|
69
|
+
creating,
|
|
73
70
|
pollIntervalMs,
|
|
74
71
|
validateApplePayMerchant,
|
|
75
72
|
}: {
|
|
@@ -79,6 +76,9 @@ function PaymentBody({
|
|
|
79
76
|
method: PaymentMethod | null;
|
|
80
77
|
tenantSlug?: string;
|
|
81
78
|
onResolved: (status: OrderStatus) => void;
|
|
79
|
+
/** Set only when the shell hid its picker — see {@link PaymentStep}. */
|
|
80
|
+
onStart?: () => void;
|
|
81
|
+
creating: boolean;
|
|
82
82
|
pollIntervalMs?: number;
|
|
83
83
|
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
84
84
|
}): JSX.Element | null {
|
|
@@ -91,6 +91,8 @@ function PaymentBody({
|
|
|
91
91
|
method={method}
|
|
92
92
|
tenantSlug={tenantSlug}
|
|
93
93
|
onResolved={onResolved}
|
|
94
|
+
onStart={onStart}
|
|
95
|
+
creating={creating}
|
|
94
96
|
pollIntervalMs={pollIntervalMs}
|
|
95
97
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
96
98
|
/>
|
|
@@ -303,6 +305,17 @@ interface PaymentStepProps {
|
|
|
303
305
|
* Step 2 "Pagamento" — pick PIX or card and pay on the SAME page. Selecting a
|
|
304
306
|
* method auto-raises its order and reveals its UI (PIX QR / card form) with no
|
|
305
307
|
* intermediate tap; switching method clears the previous order (controller).
|
|
308
|
+
*
|
|
309
|
+
* ## Unless the choice is not ours to ask
|
|
310
|
+
*
|
|
311
|
+
* A store that finishes checkout on the provider's own page gets NO picker
|
|
312
|
+
* here (`methodChosenAtProvider`). Its screen renders a single "Seguir para o
|
|
313
|
+
* pagamento" instead, and pressing it selects the store's hand-off method —
|
|
314
|
+
* which is the same event a tile press is, so the auto-raise, the error panel
|
|
315
|
+
* and the retry below all keep working unchanged. Preselection is suppressed
|
|
316
|
+
* for the same flow, and deliberately: it exists to spare a buyer a tap that
|
|
317
|
+
* buys them nothing, but here the tap is the buyer's consent to LEAVE, and
|
|
318
|
+
* taking it for them would redirect a checkout the moment it rendered.
|
|
306
319
|
*/
|
|
307
320
|
export function PaymentStep({
|
|
308
321
|
method,
|
|
@@ -323,35 +336,42 @@ export function PaymentStep({
|
|
|
323
336
|
onResolved,
|
|
324
337
|
}: PaymentStepProps): JSX.Element {
|
|
325
338
|
const { LoadingState } = useCheckoutComponents();
|
|
326
|
-
const
|
|
327
|
-
const
|
|
339
|
+
const config = providerConfig ?? null;
|
|
340
|
+
const choice = useMethodChoice(config, method, onMethodChange);
|
|
328
341
|
useAutoRaiseOrder(order, method, creating, createError, onGenerate);
|
|
329
|
-
usePreselectSoleMethod(selectableMethods(offered, cardUnavailable), method, onMethodChange);
|
|
330
342
|
|
|
331
343
|
return (
|
|
332
344
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
333
345
|
{/* Self-hiding: renders only for a flow whose Dados step was skipped. */}
|
|
334
346
|
<PayerSummary name={buyer.name} taxId={buyer.taxId} onEdit={onEditBuyer} />
|
|
335
347
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
348
|
+
{choice.atProvider ? null : (
|
|
349
|
+
<MethodPicker
|
|
350
|
+
value={method}
|
|
351
|
+
onChange={onMethodChange}
|
|
352
|
+
cardUnavailable={choice.cardUnavailable}
|
|
353
|
+
offered={choice.offered}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
342
356
|
|
|
343
357
|
<PaymentBody
|
|
344
358
|
order={order}
|
|
345
359
|
buyer={buyer}
|
|
346
|
-
providerConfig={
|
|
360
|
+
providerConfig={config}
|
|
347
361
|
method={method}
|
|
348
362
|
tenantSlug={tenantSlug}
|
|
349
363
|
onResolved={onResolved}
|
|
364
|
+
onStart={choice.onStart}
|
|
365
|
+
creating={creating}
|
|
350
366
|
pollIntervalMs={pollIntervalMs}
|
|
351
367
|
validateApplePayMerchant={validateApplePayMerchant}
|
|
352
368
|
/>
|
|
353
369
|
|
|
354
|
-
{
|
|
370
|
+
{/* The shell's own busy spinner, SUPPRESSED for a hand-off screen: that
|
|
371
|
+
screen renders its own "Preparando o pagamento" while the charge is
|
|
372
|
+
raised, and two stacked spinners saying the same thing is what the
|
|
373
|
+
buyer actually saw. */}
|
|
374
|
+
{!order && creating && !choice.atProvider ? (
|
|
355
375
|
<LoadingState variant="spinner" size="md" message="Gerando pagamento…" dataTestId="payment-generating" />
|
|
356
376
|
) : null}
|
|
357
377
|
|
|
@@ -216,6 +216,33 @@ export function selectableMethods(
|
|
|
216
216
|
);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The method a HAND-OFF checkout raises on the buyer's behalf, when the buyer
|
|
221
|
+
* was never asked (see `providers/registry.ts`'s `methodChosenAtProvider`).
|
|
222
|
+
*
|
|
223
|
+
* A charge still has to be raised with SOME method — that is the wire's shape,
|
|
224
|
+
* and the link is minted by raising it — but for a hand-off store the value is
|
|
225
|
+
* provisional by construction: the buyer picks for real on the provider's page,
|
|
226
|
+
* and the settlement reports back which one they actually used. So the choice
|
|
227
|
+
* here is about which request the SERVER will honour, not about what the buyer
|
|
228
|
+
* gets.
|
|
229
|
+
*
|
|
230
|
+
* PIX when the chain offers it, because PIX is the one method whose first
|
|
231
|
+
* charge is always raised immediately: a CARD request at a store that can
|
|
232
|
+
* tokenize somewhere in its chain is answered with the bare order instead, and
|
|
233
|
+
* there is no hand-off link on that answer to send anyone anywhere. Otherwise
|
|
234
|
+
* the chain's first declared method, since a store that cannot PIX would have
|
|
235
|
+
* the walk refuse a PIX charge outright.
|
|
236
|
+
*
|
|
237
|
+
* `null` offered — still loading, or a fetch blip — cannot reach here: the
|
|
238
|
+
* picker is only hidden for a store whose config already said it hands over.
|
|
239
|
+
* PIX is the safe reading of it anyway, for the reason above.
|
|
240
|
+
*/
|
|
241
|
+
export function handOffMethod(offered: PaymentMethod[] | null): PaymentMethod {
|
|
242
|
+
if (!offered || offered.includes("PIX")) return "PIX";
|
|
243
|
+
return offered[0] ?? "PIX";
|
|
244
|
+
}
|
|
245
|
+
|
|
219
246
|
/**
|
|
220
247
|
* A SOLE remaining method is not a choice — take it (FUT-697 review, widened by
|
|
221
248
|
* FUT-741).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO asks the buyer PIX-or-card, and what the Pagamento step does about it.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because the answer is a set of derived facts that must move
|
|
5
|
+
* together: a picker rendered for a store that hands the buyer over, or a
|
|
6
|
+
* hand-off CTA rendered beside a picker, are both a checkout asking one
|
|
7
|
+
* question twice — which is the defect this seam exists to make impossible.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
cardPathAvailable,
|
|
11
|
+
handOffMethod,
|
|
12
|
+
offeredMethods,
|
|
13
|
+
selectableMethods,
|
|
14
|
+
usePreselectSoleMethod,
|
|
15
|
+
} from "./method-capability";
|
|
16
|
+
import { methodChosenAtProvider } from "./providers/registry";
|
|
17
|
+
import type { CheckoutProviderConfig, PaymentMethod } from "./types";
|
|
18
|
+
|
|
19
|
+
/** What the Pagamento step needs to know about WHO asks the buyer for a method. */
|
|
20
|
+
interface MethodChoice {
|
|
21
|
+
/** The store's active provider has no card path in this browser. */
|
|
22
|
+
cardUnavailable: boolean;
|
|
23
|
+
/** The methods the chain declares it can charge, or `null` while unknown. */
|
|
24
|
+
offered: PaymentMethod[] | null;
|
|
25
|
+
/** The choice is made on the provider's page ⇒ render no picker here. */
|
|
26
|
+
atProvider: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The hand-off screen's "start paying" port, or `undefined` when the picker
|
|
29
|
+
* is on the page and owns that job instead. It SELECTS the store's hand-off
|
|
30
|
+
* method, which is the same event a tile press is — so the auto-raise, the
|
|
31
|
+
* error panel and its retry all keep working with no second code path.
|
|
32
|
+
*/
|
|
33
|
+
onStart?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve who asks the buyer PIX-or-card, and preselect a sole method when the
|
|
38
|
+
* question is ours to ask.
|
|
39
|
+
*/
|
|
40
|
+
export function useMethodChoice(
|
|
41
|
+
config: CheckoutProviderConfig | null,
|
|
42
|
+
method: PaymentMethod | null,
|
|
43
|
+
onMethodChange: (method: PaymentMethod) => void,
|
|
44
|
+
): MethodChoice {
|
|
45
|
+
const cardUnavailable = !cardPathAvailable(config);
|
|
46
|
+
const offered = offeredMethods(config);
|
|
47
|
+
const atProvider = methodChosenAtProvider(config?.chain?.[0]?.checkoutScreen, config);
|
|
48
|
+
// Nothing to preselect when the screen owns the choice: the whole point of
|
|
49
|
+
// its button is that the buyer presses it. Preselection exists to spare them
|
|
50
|
+
// a tap that buys them nothing, and here the tap is their consent to LEAVE —
|
|
51
|
+
// taking it for them would redirect a checkout the moment it rendered.
|
|
52
|
+
usePreselectSoleMethod(
|
|
53
|
+
atProvider ? [] : selectableMethods(offered, cardUnavailable),
|
|
54
|
+
method,
|
|
55
|
+
onMethodChange,
|
|
56
|
+
);
|
|
57
|
+
if (!atProvider) return { cardUnavailable, offered, atProvider };
|
|
58
|
+
return {
|
|
59
|
+
cardUnavailable,
|
|
60
|
+
offered,
|
|
61
|
+
atProvider,
|
|
62
|
+
onStart: () => onMethodChange(handOffMethod(offered)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -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. */
|
|
@@ -250,6 +250,17 @@ export interface CheckoutCustomerField {
|
|
|
250
250
|
*/
|
|
251
251
|
export interface CheckoutChainLink {
|
|
252
252
|
provider: string;
|
|
253
|
+
/**
|
|
254
|
+
* The provider's own name as a BUYER should read it ("InfinitePay"), which
|
|
255
|
+
* `GET /api/checkout/config` publishes per entry.
|
|
256
|
+
*
|
|
257
|
+
* Optional, and the DEGRADE DIRECTION IS "SAY NOTHING": an older host serves
|
|
258
|
+
* no name, and a hand-off screen then describes where the buyer is going
|
|
259
|
+
* without naming it, rather than printing the internal `provider` id — a
|
|
260
|
+
* buyer shown "infinitepay" learns less than one shown nothing, and learns
|
|
261
|
+
* it in a vocabulary that is ours rather than theirs.
|
|
262
|
+
*/
|
|
263
|
+
displayName?: string | null;
|
|
253
264
|
tokenization: "NONE" | "PUBLIC_KEY" | "SDK" | "REDIRECT";
|
|
254
265
|
publicKey: string | null;
|
|
255
266
|
mockTokenization: boolean;
|