@12-apps/payments-frontend 1.17.0 → 1.19.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/ProviderConnection.tsx +54 -0
- package/src/components/checkout/apple-pay-button.tsx +189 -0
- package/src/components/checkout/checkout-flow.tsx +9 -1
- package/src/components/checkout/checkout-steps.tsx +7 -0
- package/src/components/checkout/client-context.tsx +6 -1
- package/src/components/checkout/google-pay-button.tsx +12 -2
- package/src/components/checkout/method-capability.ts +18 -0
- package/src/components/checkout/providers/types.ts +8 -0
- package/src/components/checkout/transport.ts +64 -4
- package/src/components/checkout/use-wallet-charge.ts +12 -6
- package/src/components/checkout/wallet-pane.tsx +50 -24
- package/src/components/platform/ConnectApplicationPanel.tsx +77 -0
- package/src/components/platform/ConnectEnvironmentCard.tsx +175 -0
- package/src/components/platform/HomologacaoGuideCard.tsx +86 -0
- package/src/components/platform/HomologacaoOutcomeCard.tsx +170 -0
- package/src/components/platform/PlatformHomologacao.tsx +107 -0
- package/src/flows/copy.ts +28 -4
- package/src/flows/create-payment-flows.tsx +5 -1
- package/src/flows/screens-vault.tsx +280 -0
- package/src/flows/types.ts +20 -1
- package/src/flows/use-add-card.ts +183 -0
- package/src/index.ts +35 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Box, Button, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useState, type ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { HomologacaoGuide } from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import { CARD_SX } from './ConnectEnvironmentCard';
|
|
9
|
+
import { HomologacaoGuideCard } from './HomologacaoGuideCard';
|
|
10
|
+
import {
|
|
11
|
+
HomologacaoOutcomeCard,
|
|
12
|
+
type HomologacaoSaveInput,
|
|
13
|
+
type HomologacaoSaveState,
|
|
14
|
+
type PlatformHomologationRecordView,
|
|
15
|
+
} from './HomologacaoOutcomeCard';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The PLATFORM's PagBank homologação screen (FUT-483, packaged by FUT-573).
|
|
19
|
+
*
|
|
20
|
+
* The platform is the direct integrator, so the homologação is the
|
|
21
|
+
* platform's, once — store owners are platform users and are exempt. The
|
|
22
|
+
* screen carries the three halves: the recorded outcome (so "is the platform
|
|
23
|
+
* homologated?" stops being a question for a person), the Pipefy form with
|
|
24
|
+
* paste-ready answers (BOTH services — Order and Connect), and the evidence
|
|
25
|
+
* generator running on the platform's own sandbox credentials.
|
|
26
|
+
*
|
|
27
|
+
* Dumb by design: data and mutations arrive via props from the host's own
|
|
28
|
+
* mounted routes (`platformHomologacaoGuide`, `createHomologationRecordService`
|
|
29
|
+
* and `buildPlatformHomologacaoAnexo` in `@12-apps/payments-backend`), so the
|
|
30
|
+
* host page is a thin mount.
|
|
31
|
+
*/
|
|
32
|
+
export interface PlatformHomologacaoProps {
|
|
33
|
+
/** The recorded outcome; null renders the honest "não solicitada". */
|
|
34
|
+
record: PlatformHomologationRecordView | null;
|
|
35
|
+
/** The paste-ready answers, computed by the host's backend. */
|
|
36
|
+
guide: HomologacaoGuide;
|
|
37
|
+
/** Record the outcome — the host PUTs it and refreshes `record`. */
|
|
38
|
+
onSaveRecord: (input: HomologacaoSaveInput) => void;
|
|
39
|
+
/** The host's save-mutation state. */
|
|
40
|
+
save: HomologacaoSaveState;
|
|
41
|
+
/**
|
|
42
|
+
* Generate AND deliver the evidence file (the host downloads what its anexo
|
|
43
|
+
* route answers). Reject with an Error whose message names the reason —
|
|
44
|
+
* e.g. the missing platform sandbox token and where to fix it — and the
|
|
45
|
+
* card shows it verbatim.
|
|
46
|
+
*/
|
|
47
|
+
onGenerateAnexo: () => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The evidence-file half: real sandbox calls, downloaded as a text file. */
|
|
51
|
+
function AnexoCard({ onGenerate }: { onGenerate: () => Promise<void> }): ReactNode {
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
|
|
55
|
+
const generate = async (): Promise<void> => {
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
await onGenerate();
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
setError(cause instanceof Error ? cause.message : 'Não foi possível gerar o anexo.');
|
|
62
|
+
} finally {
|
|
63
|
+
setBusy(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Stack spacing={1.5} data-testid="homologacao-anexo-card" sx={CARD_SX}>
|
|
69
|
+
<Typography variant="body2" fontWeight={600}>
|
|
70
|
+
Anexo de evidências
|
|
71
|
+
</Typography>
|
|
72
|
+
<Typography variant="body2" color="text.secondary" component="p">
|
|
73
|
+
O formulário exige os requests e responses das requisições enviadas às APIs do
|
|
74
|
+
PagBank. O botão abaixo faz as chamadas reais no ambiente de testes (Sandbox) com o
|
|
75
|
+
token da própria plataforma — nada é cobrado de verdade — e baixa o arquivo pronto
|
|
76
|
+
para anexar, com o token redigido.
|
|
77
|
+
</Typography>
|
|
78
|
+
<Box>
|
|
79
|
+
<Button
|
|
80
|
+
variant="outlined"
|
|
81
|
+
size="small"
|
|
82
|
+
disabled={busy}
|
|
83
|
+
onClick={() => void generate()}
|
|
84
|
+
data-testid="homologacao-anexo-button"
|
|
85
|
+
>
|
|
86
|
+
Gerar anexo
|
|
87
|
+
</Button>
|
|
88
|
+
</Box>
|
|
89
|
+
{error !== null ? (
|
|
90
|
+
<Alert severity="error" data-testid="homologacao-anexo-error">
|
|
91
|
+
{error}
|
|
92
|
+
</Alert>
|
|
93
|
+
) : null}
|
|
94
|
+
</Stack>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function PlatformHomologacao(props: PlatformHomologacaoProps): ReactNode {
|
|
99
|
+
const { record, guide, onSaveRecord, save, onGenerateAnexo } = props;
|
|
100
|
+
return (
|
|
101
|
+
<Stack spacing={2} data-testid="platform-homologacao">
|
|
102
|
+
<HomologacaoOutcomeCard record={record} onSave={onSaveRecord} save={save} />
|
|
103
|
+
<HomologacaoGuideCard guide={guide} />
|
|
104
|
+
<AnexoCard onGenerate={onGenerateAnexo} />
|
|
105
|
+
</Stack>
|
|
106
|
+
);
|
|
107
|
+
}
|
package/src/flows/copy.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Scope is deliberately narrow and stated rather than implied: this covers the
|
|
5
5
|
* copy the FACTORY owns — the unavailable screen's two remedies, the hosted
|
|
6
|
-
* handover and its fallback link, the empty cart,
|
|
7
|
-
* action
|
|
8
|
-
* ticket (PIX, card, status)
|
|
9
|
-
*
|
|
6
|
+
* handover and its fallback link, the empty cart, the buyer form's continue
|
|
7
|
+
* action, and the add-card / manage-cards screens (FUT-183). The screens that
|
|
8
|
+
* already carried their own product copy before this ticket (PIX, card, status)
|
|
9
|
+
* keep it; moving all of it here in the same change that introduces the factory
|
|
10
|
+
* would be a copy rewrite disguised as an API.
|
|
10
11
|
*
|
|
11
12
|
* Every default below is today's pt-BR, verbatim — a host that passes no `copy`
|
|
12
13
|
* reads exactly what a buyer reads now.
|
|
@@ -34,6 +35,19 @@ export interface CheckoutCopyFE {
|
|
|
34
35
|
emptyCartAction: string;
|
|
35
36
|
/** The Dados step's primary action. */
|
|
36
37
|
continueAction: string;
|
|
38
|
+
/** The add-card screen (FUT-183): putting a card on file outside a purchase. */
|
|
39
|
+
addCardTitle: string;
|
|
40
|
+
addCardAction: string;
|
|
41
|
+
addCardPreparing: string;
|
|
42
|
+
addCardSavedTitle: string;
|
|
43
|
+
addCardSavedBody: string;
|
|
44
|
+
addCardFailedTitle: string;
|
|
45
|
+
/** The host wired no vaulting, or the provider cannot vault — the buyer can fix neither. */
|
|
46
|
+
addCardUnavailable: string;
|
|
47
|
+
/** The manage-cards screen: the buyer's cards on file, and the door to add one. */
|
|
48
|
+
manageCardsTitle: string;
|
|
49
|
+
manageCardsEmpty: string;
|
|
50
|
+
manageCardsAdd: string;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
export const DEFAULT_CHECKOUT_COPY_FE: CheckoutCopyFE = {
|
|
@@ -54,4 +68,14 @@ export const DEFAULT_CHECKOUT_COPY_FE: CheckoutCopyFE = {
|
|
|
54
68
|
emptyCartTitle: "Seu carrinho está vazio.",
|
|
55
69
|
emptyCartAction: "Ver cardápio",
|
|
56
70
|
continueAction: "Continuar",
|
|
71
|
+
addCardTitle: "Adicionar cartão",
|
|
72
|
+
addCardAction: "Salvar cartão",
|
|
73
|
+
addCardPreparing: "Preparando o formulário…",
|
|
74
|
+
addCardSavedTitle: "Cartão salvo",
|
|
75
|
+
addCardSavedBody: "Você poderá usá-lo nas próximas compras.",
|
|
76
|
+
addCardFailedTitle: "Não foi possível salvar o cartão",
|
|
77
|
+
addCardUnavailable: "Esta loja não aceita salvar cartões no momento.",
|
|
78
|
+
manageCardsTitle: "Meus cartões",
|
|
79
|
+
manageCardsEmpty: "Você ainda não tem cartões salvos.",
|
|
80
|
+
manageCardsAdd: "Adicionar cartão",
|
|
57
81
|
};
|
|
@@ -27,6 +27,7 @@ import { FlowsProvider, useResolvedConfig, type FlowsRuntime } from "./runtime";
|
|
|
27
27
|
import { buyerScreens } from "./screens-buyer";
|
|
28
28
|
import { hostedScreens } from "./screens-hosted";
|
|
29
29
|
import { payScreens, storeCannotCharge } from "./screens-pay";
|
|
30
|
+
import { vaultScreens } from "./screens-vault";
|
|
30
31
|
import type {
|
|
31
32
|
CheckoutAvailability,
|
|
32
33
|
CheckoutController,
|
|
@@ -96,6 +97,7 @@ function buildCheckout(
|
|
|
96
97
|
providerConfig={config}
|
|
97
98
|
tenantSlug={tenantSlug}
|
|
98
99
|
confirmationExtra={runtime.config.confirmation?.extra}
|
|
100
|
+
validateApplePayMerchant={ports.validateApplePayMerchant}
|
|
99
101
|
/>
|
|
100
102
|
);
|
|
101
103
|
}
|
|
@@ -116,7 +118,7 @@ function buildCheckout(
|
|
|
116
118
|
};
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
/** Assemble the
|
|
121
|
+
/** Assemble the thirteen screens from their builders. */
|
|
120
122
|
function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
|
|
121
123
|
return {
|
|
122
124
|
MethodChoice: buyerScreens.buildMethodChoice(runtime),
|
|
@@ -130,6 +132,8 @@ function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
|
|
|
130
132
|
PayerSummary: buyerScreens.buildPayerSummary(runtime),
|
|
131
133
|
SavedCards: buyerScreens.buildSavedCards(runtime),
|
|
132
134
|
EmptyCart: buyerScreens.buildEmptyCart(runtime),
|
|
135
|
+
AddCard: vaultScreens.buildAddCard(runtime),
|
|
136
|
+
ManageCards: vaultScreens.buildManageCards(runtime),
|
|
133
137
|
};
|
|
134
138
|
}
|
|
135
139
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The factory's vault screens (FUT-183), the buyer half of FUT-478: adding a
|
|
3
|
+
* card OUTSIDE a purchase, and seeing the cards already on file.
|
|
4
|
+
*
|
|
5
|
+
* Same shape as every other factory screen: thin bindings over the shared
|
|
6
|
+
* card primitives, rendered through the `CheckoutComponents` slots, wrapped in
|
|
7
|
+
* `FlowsShell` so each one works standalone. The state machine lives in
|
|
8
|
+
* `use-add-card.ts`; the views here take its controller, which is also what
|
|
9
|
+
* lets a story stage any phase as a literal without touching a network.
|
|
10
|
+
*
|
|
11
|
+
* There is deliberately NO delete affordance on the manage screen: PagBank
|
|
12
|
+
* publishes no endpoint that deletes a stored card token, so a buyer-facing
|
|
13
|
+
* "remover cartão" could only fake the removal at the provider that most needs
|
|
14
|
+
* it. Taking a card off file stays a merchant/host concern, on the admin
|
|
15
|
+
* surface's named-provider `vault/:provider/forget` row (see the backend's
|
|
16
|
+
* `checkout/flows-vault.ts`) — which is also why S2 exposes no buyer forget
|
|
17
|
+
* for this screen to call.
|
|
18
|
+
*/
|
|
19
|
+
import { Box } from "@mui/material";
|
|
20
|
+
import { useEffect, useState, type JSX } from "react";
|
|
21
|
+
|
|
22
|
+
import { NewCardForm, type SavedCard } from "../card";
|
|
23
|
+
import type { VaultedCardDisplay } from "../components/checkout/transport";
|
|
24
|
+
import { useCheckoutComponents } from "../components/checkout/ui";
|
|
25
|
+
|
|
26
|
+
import type { CheckoutCopyFE } from "./copy";
|
|
27
|
+
import { FlowsShell, type FlowsRuntime } from "./runtime";
|
|
28
|
+
import type { CheckoutScreens } from "./types";
|
|
29
|
+
import { useAddCard, type AddCardController } from "./use-add-card";
|
|
30
|
+
|
|
31
|
+
/** `visa •••• 4242`, or just the brand when the provider shared no last4. */
|
|
32
|
+
function displayLabel(display: VaultedCardDisplay): string {
|
|
33
|
+
const brand = display.brand ?? "Cartão";
|
|
34
|
+
return display.last4 ? `${brand} •••• ${display.last4}` : brand;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `Validade 12/2031`, or nothing when the provider shared no expiry. */
|
|
38
|
+
function expiryLabel(display: VaultedCardDisplay): string | null {
|
|
39
|
+
if (display.expMonth === null || display.expYear === null) return null;
|
|
40
|
+
return `Validade ${String(display.expMonth).padStart(2, "0")}/${display.expYear}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The card is on file — display metadata only, never the vault token. */
|
|
44
|
+
function SavedConfirmation({
|
|
45
|
+
display,
|
|
46
|
+
copy,
|
|
47
|
+
}: {
|
|
48
|
+
display: VaultedCardDisplay;
|
|
49
|
+
copy: CheckoutCopyFE;
|
|
50
|
+
}): JSX.Element {
|
|
51
|
+
const { Alert, Text } = useCheckoutComponents();
|
|
52
|
+
const expiry = expiryLabel(display);
|
|
53
|
+
return (
|
|
54
|
+
<Box data-testid="add-card-saved" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
55
|
+
<Alert
|
|
56
|
+
variant="info"
|
|
57
|
+
title={copy.addCardSavedTitle}
|
|
58
|
+
description={copy.addCardSavedBody}
|
|
59
|
+
showIcon
|
|
60
|
+
/>
|
|
61
|
+
<Box>
|
|
62
|
+
<Text variant="body" size="sm" weight="semibold" as="p">
|
|
63
|
+
{displayLabel(display)}
|
|
64
|
+
</Text>
|
|
65
|
+
{expiry ? (
|
|
66
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
67
|
+
{expiry}
|
|
68
|
+
</Text>
|
|
69
|
+
) : null}
|
|
70
|
+
</Box>
|
|
71
|
+
</Box>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The form phase: the shared card fields, the refusal, the save action. */
|
|
76
|
+
function AddCardForm({
|
|
77
|
+
controller,
|
|
78
|
+
copy,
|
|
79
|
+
}: {
|
|
80
|
+
controller: AddCardController;
|
|
81
|
+
copy: CheckoutCopyFE;
|
|
82
|
+
}): JSX.Element {
|
|
83
|
+
const { Alert, Button, Text } = useCheckoutComponents();
|
|
84
|
+
return (
|
|
85
|
+
<Box data-testid="add-card" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
86
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
87
|
+
{copy.addCardTitle}
|
|
88
|
+
</Text>
|
|
89
|
+
{/* No save-card opt-in: this screen IS the opt-in, so a checkbox here
|
|
90
|
+
would be a second question about the same consent. */}
|
|
91
|
+
<NewCardForm
|
|
92
|
+
card={controller.card}
|
|
93
|
+
fieldErrors={controller.fieldErrors}
|
|
94
|
+
brand={controller.brand}
|
|
95
|
+
setCard={controller.setCard}
|
|
96
|
+
setFieldErrors={controller.setFieldErrors}
|
|
97
|
+
/>
|
|
98
|
+
{controller.error ? (
|
|
99
|
+
<Alert
|
|
100
|
+
variant="danger"
|
|
101
|
+
title={copy.addCardFailedTitle}
|
|
102
|
+
description={controller.error}
|
|
103
|
+
showIcon
|
|
104
|
+
data-testid="add-card-error"
|
|
105
|
+
/>
|
|
106
|
+
) : null}
|
|
107
|
+
<Button
|
|
108
|
+
variant="solid"
|
|
109
|
+
color="primary"
|
|
110
|
+
size="lg"
|
|
111
|
+
fullWidth
|
|
112
|
+
loading={controller.saving}
|
|
113
|
+
disabled={controller.saving}
|
|
114
|
+
onClick={() => void controller.submit()}
|
|
115
|
+
dataTestId="add-card-save"
|
|
116
|
+
>
|
|
117
|
+
{copy.addCardAction}
|
|
118
|
+
</Button>
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The add-card screen body, one branch per {@link AddCardController} phase. */
|
|
124
|
+
export function AddCardView({
|
|
125
|
+
controller,
|
|
126
|
+
copy,
|
|
127
|
+
}: {
|
|
128
|
+
controller: AddCardController;
|
|
129
|
+
copy: CheckoutCopyFE;
|
|
130
|
+
}): JSX.Element {
|
|
131
|
+
const { Alert, LoadingState } = useCheckoutComponents();
|
|
132
|
+
const { phase } = controller;
|
|
133
|
+
if (phase.kind === "preparing") {
|
|
134
|
+
return (
|
|
135
|
+
<LoadingState
|
|
136
|
+
variant="spinner"
|
|
137
|
+
size="md"
|
|
138
|
+
message={copy.addCardPreparing}
|
|
139
|
+
dataTestId="add-card-preparing"
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (phase.kind === "unavailable") {
|
|
144
|
+
return (
|
|
145
|
+
<Alert
|
|
146
|
+
variant="info"
|
|
147
|
+
title={copy.addCardTitle}
|
|
148
|
+
description={phase.message}
|
|
149
|
+
showIcon
|
|
150
|
+
data-testid="add-card-unavailable"
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (phase.kind === "saved") {
|
|
155
|
+
return <SavedConfirmation display={phase.display} copy={copy} />;
|
|
156
|
+
}
|
|
157
|
+
return <AddCardForm controller={controller} copy={copy} />;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The live add-card flow — the hook and the view, bound to one runtime. */
|
|
161
|
+
function AddCardSection({
|
|
162
|
+
runtime,
|
|
163
|
+
onSaved,
|
|
164
|
+
}: {
|
|
165
|
+
runtime: FlowsRuntime;
|
|
166
|
+
onSaved?: (display: VaultedCardDisplay) => void;
|
|
167
|
+
}): JSX.Element {
|
|
168
|
+
const controller = useAddCard(runtime, onSaved);
|
|
169
|
+
return <AddCardView controller={controller} copy={runtime.copy} />;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The caller's instruments at this store (FUT-697 scoping), with a pending
|
|
174
|
+
* flag so the empty-state sentence never flashes while the list is in flight.
|
|
175
|
+
*/
|
|
176
|
+
function useInstrumentList(
|
|
177
|
+
runtime: FlowsRuntime,
|
|
178
|
+
refresh: number,
|
|
179
|
+
): { cards: SavedCard[]; pending: boolean } {
|
|
180
|
+
const tenantSlug = runtime.useTenantSlug();
|
|
181
|
+
const [state, setState] = useState<{ cards: SavedCard[]; pending: boolean }>({
|
|
182
|
+
cards: [],
|
|
183
|
+
pending: true,
|
|
184
|
+
});
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
let active = true;
|
|
187
|
+
void runtime.client.listInstruments(tenantSlug).then((cards) => {
|
|
188
|
+
if (active) setState({ cards, pending: false });
|
|
189
|
+
});
|
|
190
|
+
return () => {
|
|
191
|
+
active = false;
|
|
192
|
+
};
|
|
193
|
+
}, [runtime, tenantSlug, refresh]);
|
|
194
|
+
return state;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The list itself, or the empty-state sentence. Read-only by design. */
|
|
198
|
+
function CardList({ cards, emptyCopy }: { cards: SavedCard[]; emptyCopy: string }): JSX.Element {
|
|
199
|
+
const { Text } = useCheckoutComponents();
|
|
200
|
+
if (cards.length === 0) {
|
|
201
|
+
return (
|
|
202
|
+
<Text variant="body" size="sm" color="secondary" as="p" data-testid="manage-cards-empty">
|
|
203
|
+
{emptyCopy}
|
|
204
|
+
</Text>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return (
|
|
208
|
+
<Box data-testid="manage-cards-list" sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
209
|
+
{cards.map((card) => (
|
|
210
|
+
<Box key={card.id} data-testid={`manage-cards-item-${card.id}`}>
|
|
211
|
+
<Text variant="body" size="sm" weight="semibold" as="p">
|
|
212
|
+
{`${card.brand} •••• ${card.last4}`}
|
|
213
|
+
</Text>
|
|
214
|
+
{card.expMonth && card.expYear ? (
|
|
215
|
+
<Text variant="caption" size="xs" color="secondary" as="p">
|
|
216
|
+
{`Validade ${String(card.expMonth).padStart(2, "0")}/${card.expYear}`}
|
|
217
|
+
</Text>
|
|
218
|
+
) : null}
|
|
219
|
+
</Box>
|
|
220
|
+
))}
|
|
221
|
+
</Box>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The manage screen: the list, and the door into the add flow. */
|
|
226
|
+
function ManageCardsBody({ runtime }: { runtime: FlowsRuntime }): JSX.Element {
|
|
227
|
+
const { Button, Text } = useCheckoutComponents();
|
|
228
|
+
const copy = runtime.copy;
|
|
229
|
+
const [adding, setAdding] = useState(false);
|
|
230
|
+
// Bumped when the add flow saves, so the list re-reads what is now on file.
|
|
231
|
+
const [refresh, setRefresh] = useState(0);
|
|
232
|
+
const { cards, pending } = useInstrumentList(runtime, refresh);
|
|
233
|
+
return (
|
|
234
|
+
<Box data-testid="manage-cards" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
235
|
+
<Text variant="heading" size="md" weight="bold" as="h2">
|
|
236
|
+
{copy.manageCardsTitle}
|
|
237
|
+
</Text>
|
|
238
|
+
{pending ? null : <CardList cards={cards} emptyCopy={copy.manageCardsEmpty} />}
|
|
239
|
+
{adding ? (
|
|
240
|
+
<AddCardSection runtime={runtime} onSaved={() => setRefresh((count) => count + 1)} />
|
|
241
|
+
) : (
|
|
242
|
+
<Button
|
|
243
|
+
variant="outline"
|
|
244
|
+
color="primary"
|
|
245
|
+
size="lg"
|
|
246
|
+
fullWidth
|
|
247
|
+
onClick={() => setAdding(true)}
|
|
248
|
+
dataTestId="manage-cards-add"
|
|
249
|
+
>
|
|
250
|
+
{copy.manageCardsAdd}
|
|
251
|
+
</Button>
|
|
252
|
+
)}
|
|
253
|
+
</Box>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildAddCard(runtime: FlowsRuntime): CheckoutScreens["AddCard"] {
|
|
258
|
+
return function AddCard({ onSaved }) {
|
|
259
|
+
return (
|
|
260
|
+
<FlowsShell runtime={runtime}>
|
|
261
|
+
<AddCardSection runtime={runtime} onSaved={onSaved} />
|
|
262
|
+
</FlowsShell>
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function buildManageCards(runtime: FlowsRuntime): CheckoutScreens["ManageCards"] {
|
|
268
|
+
return function ManageCards() {
|
|
269
|
+
return (
|
|
270
|
+
<FlowsShell runtime={runtime}>
|
|
271
|
+
<ManageCardsBody runtime={runtime} />
|
|
272
|
+
</FlowsShell>
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const vaultScreens = {
|
|
278
|
+
buildAddCard,
|
|
279
|
+
buildManageCards,
|
|
280
|
+
};
|
package/src/flows/types.ts
CHANGED
|
@@ -37,7 +37,7 @@ import type {
|
|
|
37
37
|
OrderStatus,
|
|
38
38
|
PaymentMethod,
|
|
39
39
|
} from "../components/checkout/types";
|
|
40
|
-
import type { CheckoutTransport } from "../components/checkout/transport";
|
|
40
|
+
import type { CheckoutTransport, VaultedCardDisplay } from "../components/checkout/transport";
|
|
41
41
|
import type { useCheckoutController } from "../components/checkout/use-checkout-controller";
|
|
42
42
|
import type { Result } from "../result";
|
|
43
43
|
|
|
@@ -74,6 +74,13 @@ export interface CheckoutPorts {
|
|
|
74
74
|
* some hosts must log or confirm the departure.
|
|
75
75
|
*/
|
|
76
76
|
navigate?(url: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Apple Pay merchant validation (FUT-472): exchange the session's
|
|
79
|
+
* `validationURL` for an Apple merchant session, SERVER-SIDE — the merchant
|
|
80
|
+
* identity certificate must never reach a browser. Optional; without it the
|
|
81
|
+
* Apple Pay sheet cannot start and the card form remains the way to pay.
|
|
82
|
+
*/
|
|
83
|
+
validateApplePayMerchant?(validationURL: string): Promise<unknown>;
|
|
77
84
|
/** The remedy shown on the no-provider screen, AND the host's veto. */
|
|
78
85
|
useAvailability?(): CheckoutAvailability;
|
|
79
86
|
}
|
|
@@ -157,6 +164,18 @@ export interface CheckoutScreens {
|
|
|
157
164
|
PayerSummary: ComponentType<{ buyer: BuyerInfo; onEdit?(): void }>;
|
|
158
165
|
SavedCards: ComponentType<{ selection: string; onSelect(id: string): void }>;
|
|
159
166
|
EmptyCart: ComponentType<Record<string, never>>;
|
|
167
|
+
/**
|
|
168
|
+
* Put a card on file OUTSIDE a purchase (FUT-183, over FUT-478's
|
|
169
|
+
* `/cards/begin` + `/cards/complete`). `onSaved` fires with display metadata
|
|
170
|
+
* only — the vault token never reaches the browser.
|
|
171
|
+
*/
|
|
172
|
+
AddCard: ComponentType<{ onSaved?(display: VaultedCardDisplay): void }>;
|
|
173
|
+
/**
|
|
174
|
+
* The buyer's saved cards, plus the door into {@link CheckoutScreens.AddCard}.
|
|
175
|
+
* Read-only beyond that: there is no buyer-side delete (PagBank publishes no
|
|
176
|
+
* token-delete endpoint — see `flows/screens-vault.tsx`).
|
|
177
|
+
*/
|
|
178
|
+
ManageCards: ComponentType<Record<string, never>>;
|
|
160
179
|
}
|
|
161
180
|
|
|
162
181
|
/** The fetched store protocol, plus whether it is still in flight. */
|