@12-apps/payments-frontend 1.18.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/ConnectionProbe.tsx +78 -0
- package/src/components/ProviderPanel.tsx +60 -6
- package/src/components/checkout/client-context.tsx +6 -1
- package/src/components/checkout/transport.ts +64 -4
- package/src/flows/copy.ts +28 -4
- package/src/flows/create-payment-flows.tsx +4 -1
- package/src/flows/screens-vault.tsx +280 -0
- package/src/flows/types.ts +13 -1
- package/src/flows/use-add-card.ts +183 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.1",
|
|
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": "^1.
|
|
20
|
+
"@12-apps/payments-backend": "^1.22.2",
|
|
21
21
|
"react-qr-code": "^2.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Button, CircularProgress, Stack } from '@mui/material';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
9
|
+
import { isConnected } from './connection-state';
|
|
10
|
+
import { ProbeAlert, type VerifyProbe } from './CredentialFormAlerts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Testar conexão" for a store whose connection is a GRANT, not a form.
|
|
14
|
+
*
|
|
15
|
+
* On the credentials path the probe runs off the save button — saving IS
|
|
16
|
+
* testing — so it lives inside the credential form. An OAuth store never opens
|
|
17
|
+
* that form: it sits behind the "prefiro informar as credenciais manualmente"
|
|
18
|
+
* disclosure, which the connect card above it says there is no reason to open.
|
|
19
|
+
* The store's own guide told the owner to press "Testar conexão", and the
|
|
20
|
+
* screen offered no such button anywhere they would look (FUT-691).
|
|
21
|
+
*
|
|
22
|
+
* Rendered only once there is a connection to test (`isConnected`, which
|
|
23
|
+
* includes RECONNECT_REQUIRED — a dead grant is exactly what an owner wants to
|
|
24
|
+
* probe). A passing probe is reported by the persistent status chip in the
|
|
25
|
+
* header, exactly as the form's probe is (see {@link ProbeAlert} for why a
|
|
26
|
+
* green banner would say nothing the chip does not); a failure gets the
|
|
27
|
+
* adapter's own sentence.
|
|
28
|
+
*/
|
|
29
|
+
export function ConnectionProbe({
|
|
30
|
+
descriptor,
|
|
31
|
+
config,
|
|
32
|
+
client,
|
|
33
|
+
reload,
|
|
34
|
+
}: {
|
|
35
|
+
descriptor: ProviderDescriptor;
|
|
36
|
+
config: MaskedProviderConfig | null;
|
|
37
|
+
client: PaymentsSettingsClient;
|
|
38
|
+
/** Refresh the settings view — the probe may have moved the stored status. */
|
|
39
|
+
reload: () => void;
|
|
40
|
+
}) {
|
|
41
|
+
const [busy, setBusy] = useState(false);
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
const [probe, setProbe] = useState<VerifyProbe | null>(null);
|
|
44
|
+
if (!isConnected(config)) return null;
|
|
45
|
+
|
|
46
|
+
const verify = async () => {
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
try {
|
|
50
|
+
// The ACTIVE environment: it is the one the grant was sealed into and
|
|
51
|
+
// the one the connection card above describes.
|
|
52
|
+
const verified = await client.verify(descriptor.name, config?.environment);
|
|
53
|
+
setProbe(verified.probe);
|
|
54
|
+
reload();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
57
|
+
} finally {
|
|
58
|
+
setBusy(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Stack spacing={1} alignItems="flex-start" data-testid="payments-connection-probe">
|
|
64
|
+
<Button
|
|
65
|
+
variant="outlined"
|
|
66
|
+
size="small"
|
|
67
|
+
data-testid="payments-oauth-verify"
|
|
68
|
+
disabled={busy}
|
|
69
|
+
onClick={() => void verify()}
|
|
70
|
+
sx={{ textTransform: 'none' }}
|
|
71
|
+
>
|
|
72
|
+
{busy ? <CircularProgress size={18} /> : 'Testar conexão'}
|
|
73
|
+
</Button>
|
|
74
|
+
{error ? <Alert severity="error">{error}</Alert> : null}
|
|
75
|
+
{probe ? <ProbeAlert probe={probe} busy={busy} onRetry={() => void verify()} /> : null}
|
|
76
|
+
</Stack>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
} from '@12-apps/payments-backend';
|
|
20
20
|
|
|
21
21
|
import type { PaymentsSettingsClient } from '../client';
|
|
22
|
+
import { ConnectionProbe } from './ConnectionProbe';
|
|
22
23
|
import { EnvironmentNotice, EnvironmentSelector } from './EnvironmentTabs';
|
|
23
24
|
import { ProviderConnection } from './ProviderConnection';
|
|
24
25
|
import { ProviderForm } from './ProviderCredentialForm';
|
|
@@ -57,10 +58,13 @@ export interface ActivePanelProps {
|
|
|
57
58
|
* physically contains the field for that step, because "informe sua
|
|
58
59
|
* InfiniteTag" and the box you type it into are one thing.
|
|
59
60
|
*
|
|
60
|
-
* On the OAUTH path
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* On the OAUTH path with a working connect button the walkthrough renders
|
|
62
|
+
* OUTSIDE the manual disclosure (slots empty — the credential FORM stays
|
|
63
|
+
* inside it). It used to be stuffed in there whole, which buried the
|
|
64
|
+
* provider's own setup guide behind "prefiro informar as credenciais
|
|
65
|
+
* manualmente" — a label the connect card above explicitly says there is no
|
|
66
|
+
* reason to open (FUT-691). Deliberately shared behavior: every OAuth
|
|
67
|
+
* provider's screen changes shape, PagBank's included.
|
|
64
68
|
*/
|
|
65
69
|
guide?: (slots: {
|
|
66
70
|
rows: ReactNode;
|
|
@@ -166,6 +170,35 @@ function requiredStored(
|
|
|
166
170
|
.every((spec) => stored[spec.key]?.configured === true);
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Does this provider get the connect-button branch of {@link OAuthPanel}?
|
|
175
|
+
* With a working connect button the walkthrough leaves the form; without one
|
|
176
|
+
* the form IS the path and keeps the guide interleaved.
|
|
177
|
+
*/
|
|
178
|
+
function oauthWithConnect(props: ActivePanelProps): boolean {
|
|
179
|
+
return props.descriptor.authMode === 'oauth' && Boolean(props.prepareConnect);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The walkthrough as the OAUTH connect branch renders it: standalone, slots
|
|
184
|
+
* empty (null on every other branch, where the form carries the guide).
|
|
185
|
+
*
|
|
186
|
+
* The credential form stays inside the manual disclosure, so the guide gets no
|
|
187
|
+
* rows and no live field — and `stored` answers for the ACTIVE environment,
|
|
188
|
+
* which is the one the connect card and the server-computed stage describe
|
|
189
|
+
* (the tabs inside the disclosure govern the form, not this).
|
|
190
|
+
*/
|
|
191
|
+
function oauthWalkthrough(props: ActivePanelProps): ReactNode {
|
|
192
|
+
const { descriptor, config, guide } = props;
|
|
193
|
+
if (!guide || !oauthWithConnect(props)) return null;
|
|
194
|
+
return guide({
|
|
195
|
+
rows: null,
|
|
196
|
+
sectionFooter: null,
|
|
197
|
+
editing: false,
|
|
198
|
+
stored: requiredStored(descriptor, config, config?.environment ?? 'SANDBOX'),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
169
202
|
export function ActivePanel(props: ActivePanelProps) {
|
|
170
203
|
const { descriptor, config, client, onChanged, reload, guide, verification } = props;
|
|
171
204
|
const statusBar = <EnableBar {...props} />;
|
|
@@ -180,6 +213,10 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
180
213
|
// and the one that costs money is the one you did not ask for.
|
|
181
214
|
const [editing, setEditing] = useState(false);
|
|
182
215
|
|
|
216
|
+
// On the connect branch the walkthrough leaves the form (see the `guide`
|
|
217
|
+
// prop): the form then stands alone inside the manual disclosure.
|
|
218
|
+
const oauthConnect = oauthWithConnect(props);
|
|
219
|
+
|
|
183
220
|
const credentials = (
|
|
184
221
|
<ProviderForm
|
|
185
222
|
descriptor={descriptor}
|
|
@@ -191,7 +228,7 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
191
228
|
onChanged={onChanged}
|
|
192
229
|
onSaved={reload}
|
|
193
230
|
onCredentialsReplaced={props.onCredentialsReplaced}
|
|
194
|
-
renderGuide={guide}
|
|
231
|
+
renderGuide={oauthConnect ? undefined : guide}
|
|
195
232
|
/>
|
|
196
233
|
);
|
|
197
234
|
|
|
@@ -226,6 +263,7 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
226
263
|
<OAuthPanel
|
|
227
264
|
{...props}
|
|
228
265
|
statusBar={statusBar}
|
|
266
|
+
walkthrough={oauthWalkthrough(props)}
|
|
229
267
|
form={
|
|
230
268
|
<Stack spacing={2}>
|
|
231
269
|
{selector}
|
|
@@ -254,8 +292,14 @@ function OAuthPanel({
|
|
|
254
292
|
prepareConnect,
|
|
255
293
|
verification,
|
|
256
294
|
statusBar,
|
|
295
|
+
walkthrough,
|
|
257
296
|
form,
|
|
258
|
-
}: ActivePanelProps & { statusBar: ReactNode; form: ReactNode }) {
|
|
297
|
+
}: ActivePanelProps & { statusBar: ReactNode; walkthrough: ReactNode; form: ReactNode }) {
|
|
298
|
+
// The connection probe, for a store whose connection is a grant: the form's
|
|
299
|
+
// own probe runs off Salvar, which an OAuth store never presses (FUT-691).
|
|
300
|
+
const probe = (
|
|
301
|
+
<ConnectionProbe descriptor={descriptor} config={config} client={client} reload={reload} />
|
|
302
|
+
);
|
|
259
303
|
if (!prepareConnect) {
|
|
260
304
|
return (
|
|
261
305
|
<ProviderCard header={statusBar}>
|
|
@@ -264,6 +308,7 @@ function OAuthPanel({
|
|
|
264
308
|
instalação. Você ainda pode conectar informando as credenciais manualmente.
|
|
265
309
|
</Alert>
|
|
266
310
|
{form}
|
|
311
|
+
{probe}
|
|
267
312
|
{verification}
|
|
268
313
|
</ProviderCard>
|
|
269
314
|
);
|
|
@@ -285,6 +330,15 @@ function OAuthPanel({
|
|
|
285
330
|
manualmente" to find it.
|
|
286
331
|
*/}
|
|
287
332
|
{verification}
|
|
333
|
+
{/*
|
|
334
|
+
The provider's walkthrough, OUTSIDE the disclosure for the same reason
|
|
335
|
+
as the activation step above it: it used to live inside the credential
|
|
336
|
+
form, which on this branch is folded into the manual fallback — so the
|
|
337
|
+
guide (and the stepper answering "where am I") was buried behind a
|
|
338
|
+
label the connect card says there is no reason to open (FUT-691).
|
|
339
|
+
*/}
|
|
340
|
+
{walkthrough}
|
|
341
|
+
{probe}
|
|
288
342
|
{descriptor.credentialSchema.length > 0 ? (
|
|
289
343
|
<Accordion disableGutters data-testid="payments-manual-fallback">
|
|
290
344
|
<AccordionSummary expandIcon={<span aria-hidden>▾</span>}>
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
pollOrderStatus,
|
|
27
27
|
refreshCardPublicKey,
|
|
28
28
|
} from "./client";
|
|
29
|
-
import type
|
|
29
|
+
import { createCheckoutClient, type CheckoutClient } from "./transport";
|
|
30
30
|
|
|
31
31
|
/** The unbound client: `/api/checkout` on the ambient `fetch`. */
|
|
32
32
|
const DEFAULT_CLIENT: CheckoutClient = {
|
|
@@ -35,6 +35,11 @@ const DEFAULT_CLIENT: CheckoutClient = {
|
|
|
35
35
|
charge: (input) => chargeCard(input),
|
|
36
36
|
chargeWallet: (input) => chargeWallet(input),
|
|
37
37
|
listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
|
|
38
|
+
// The vault pair (FUT-183) has no `client.ts` free function to bind — it is
|
|
39
|
+
// newer than that module. Built lazily from the default transport instead,
|
|
40
|
+
// which is the same wire: `/api/checkout`, ambient `fetch` resolved per call.
|
|
41
|
+
beginVault: () => createCheckoutClient().beginVault(),
|
|
42
|
+
completeVault: (input) => createCheckoutClient().completeVault(input),
|
|
38
43
|
refreshBrowserKey: (input) => refreshCardPublicKey(input),
|
|
39
44
|
};
|
|
40
45
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The checkout's HTTP transport, as a bound client (FUT-741).
|
|
3
3
|
*
|
|
4
4
|
* Everything `client.ts` used to do with a hard-coded prefix and the ambient
|
|
5
|
-
* `fetch` now lives here behind {@link createCheckoutClient}, so the same
|
|
5
|
+
* `fetch` now lives here behind {@link createCheckoutClient}, so the same
|
|
6
6
|
* calls can be pointed at a different mount, carry a host's auth headers, or —
|
|
7
7
|
* the reason this exists — be driven through an injected `fetch` that routes
|
|
8
8
|
* straight into a real `createPaymentFlowsBE` mount. A story or a harness page
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* shipped contract in the same release that introduces the factory.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import type { BuyerVaultSession, VaultedCardDisplay } from "@12-apps/payments-backend";
|
|
19
|
+
|
|
18
20
|
import type { SavedCard } from "../../card";
|
|
19
21
|
import { err, ok, type Result } from "../../result";
|
|
20
22
|
|
|
@@ -26,6 +28,28 @@ import type {
|
|
|
26
28
|
OrderStatus,
|
|
27
29
|
} from "./types";
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The two buyer-vault answer shapes (FUT-478/FUT-183), imported as TYPES from
|
|
33
|
+
* the backend package rather than mirrored: `/cards/begin` and `/cards/complete`
|
|
34
|
+
* are new rows with no older-host degrade story to encode, so a mirror here
|
|
35
|
+
* would only be a copy that can drift from the wire it names. Re-exported for
|
|
36
|
+
* the same reason `PaymentEnvironment` is on the barrel — a host typing its
|
|
37
|
+
* own callback must not need a direct backend dependency.
|
|
38
|
+
*/
|
|
39
|
+
export type { BuyerVaultSession, VaultedCardDisplay };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The browser's two legitimate contributions to `POST /cards/complete`: the
|
|
43
|
+
* session it confirmed, and — for a sessionless PUBLIC_KEY provider — the
|
|
44
|
+
* encrypted card blob. The ownership facts (`reference`, `customerRef`) are
|
|
45
|
+
* answered server-side by the host's vault port and are NOT here on purpose:
|
|
46
|
+
* a body naming them is ignored by the mount.
|
|
47
|
+
*/
|
|
48
|
+
export interface CompleteVaultInput {
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
token?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
/**
|
|
30
54
|
* The prefix every shipped buyer checkout posts to today. Exported so a host
|
|
31
55
|
* (or a test) can state it rather than re-type it, and so a change to it is a
|
|
@@ -35,7 +59,10 @@ export const DEFAULT_CHECKOUT_BASE_URL = "/api/checkout";
|
|
|
35
59
|
|
|
36
60
|
/** Where the `createPaymentFlowsBE` mount lives, and how to reach it. */
|
|
37
61
|
export interface CheckoutTransport {
|
|
38
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Prefix for `/config`, `/status`, `/charge`, `/cards`, `/cards/begin`,
|
|
64
|
+
* `/cards/complete`, `/refresh-key`.
|
|
65
|
+
*/
|
|
39
66
|
baseUrl?: string;
|
|
40
67
|
/**
|
|
41
68
|
* The `fetch` to call. Omitted ⇒ the ambient one, resolved PER CALL so a
|
|
@@ -46,7 +73,7 @@ export interface CheckoutTransport {
|
|
|
46
73
|
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
47
74
|
}
|
|
48
75
|
|
|
49
|
-
/** The
|
|
76
|
+
/** The eight calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
|
|
50
77
|
export interface CheckoutClient {
|
|
51
78
|
getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
|
|
52
79
|
getStatus(ref: string): Promise<Result<OrderStatus>>;
|
|
@@ -54,6 +81,18 @@ export interface CheckoutClient {
|
|
|
54
81
|
/** A wallet instrument against the same `/charge` route (FUT-471/472). */
|
|
55
82
|
chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
|
|
56
83
|
listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
|
|
84
|
+
/**
|
|
85
|
+
* `POST /cards/begin` (FUT-478): equip the browser to mint an instrument
|
|
86
|
+
* OUTSIDE a purchase. The answer names the tokenization scheme, the public
|
|
87
|
+
* key when the provider has one, and the session to echo to `completeVault`.
|
|
88
|
+
*/
|
|
89
|
+
beginVault(): Promise<Result<BuyerVaultSession>>;
|
|
90
|
+
/**
|
|
91
|
+
* `POST /cards/complete`: the provider accepted the card — the server stores
|
|
92
|
+
* the vault token against the caller and answers DISPLAY metadata only. The
|
|
93
|
+
* token that can charge never reaches the browser.
|
|
94
|
+
*/
|
|
95
|
+
completeVault(input: CompleteVaultInput): Promise<Result<VaultedCardDisplay>>;
|
|
57
96
|
refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
|
|
58
97
|
}
|
|
59
98
|
|
|
@@ -146,7 +185,20 @@ function flatWalletBody(input: ChargeWalletInput): string {
|
|
|
146
185
|
}
|
|
147
186
|
|
|
148
187
|
/**
|
|
149
|
-
* The
|
|
188
|
+
* The wire body of `POST /cards/complete` — ONLY the browser's two facts, and
|
|
189
|
+
* each present only when it exists. `flows-vault.ts` reads exactly these two
|
|
190
|
+
* string fields (`browserVaultFacts`) and ignores everything else, so a field
|
|
191
|
+
* added here without a backend reader would be silently dropped.
|
|
192
|
+
*/
|
|
193
|
+
function completeVaultBody(input: CompleteVaultInput): string {
|
|
194
|
+
return JSON.stringify({
|
|
195
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
196
|
+
...(input.token ? { token: input.token } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The checkout calls, bound to one transport.
|
|
150
202
|
*
|
|
151
203
|
* Passing no transport reproduces exactly what the free functions in
|
|
152
204
|
* `client.ts` have always done: `/api/checkout/**` on the ambient `fetch`.
|
|
@@ -203,6 +255,14 @@ export function createCheckoutClient(transport: CheckoutTransport = {}): Checkou
|
|
|
203
255
|
return result.ok ? result.data : [];
|
|
204
256
|
},
|
|
205
257
|
|
|
258
|
+
beginVault: () => call<BuyerVaultSession>("/cards/begin", { method: "POST" }),
|
|
259
|
+
|
|
260
|
+
completeVault: (input) =>
|
|
261
|
+
call<VaultedCardDisplay>("/cards/complete", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
body: completeVaultBody(input),
|
|
264
|
+
}),
|
|
265
|
+
|
|
206
266
|
refreshBrowserKey: (input) =>
|
|
207
267
|
call<{ publicKey: string | null }>("/refresh-key", {
|
|
208
268
|
method: "POST",
|
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,
|
|
@@ -117,7 +118,7 @@ function buildCheckout(
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
/** Assemble the
|
|
121
|
+
/** Assemble the thirteen screens from their builders. */
|
|
121
122
|
function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
|
|
122
123
|
return {
|
|
123
124
|
MethodChoice: buyerScreens.buildMethodChoice(runtime),
|
|
@@ -131,6 +132,8 @@ function buildScreens(runtime: FlowsRuntime): CheckoutScreens {
|
|
|
131
132
|
PayerSummary: buyerScreens.buildPayerSummary(runtime),
|
|
132
133
|
SavedCards: buyerScreens.buildSavedCards(runtime),
|
|
133
134
|
EmptyCart: buyerScreens.buildEmptyCart(runtime),
|
|
135
|
+
AddCard: vaultScreens.buildAddCard(runtime),
|
|
136
|
+
ManageCards: vaultScreens.buildManageCards(runtime),
|
|
134
137
|
};
|
|
135
138
|
}
|
|
136
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
|
|
|
@@ -164,6 +164,18 @@ export interface CheckoutScreens {
|
|
|
164
164
|
PayerSummary: ComponentType<{ buyer: BuyerInfo; onEdit?(): void }>;
|
|
165
165
|
SavedCards: ComponentType<{ selection: string; onSelect(id: string): void }>;
|
|
166
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>>;
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
/** The fetched store protocol, plus whether it is still in flight. */
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The add-card state machine (FUT-183) — the buyer half of the FUT-478 vault
|
|
3
|
+
* surface: `POST /cards/begin` equips this browser, the shared card form and
|
|
4
|
+
* tokenizer mint the instrument, `POST /cards/complete` stores it and answers
|
|
5
|
+
* display metadata. Extracted from the view for the same reason
|
|
6
|
+
* `use-card-checkout.ts` is: the screen stays presentational, and a story can
|
|
7
|
+
* stage any phase by building a {@link AddCardController} literal.
|
|
8
|
+
*
|
|
9
|
+
* What never appears here is as deliberate as what does:
|
|
10
|
+
*
|
|
11
|
+
* - no ownership facts. `reference`/`customerRef` are the HOST's answer to
|
|
12
|
+
* the mount's vault port; the browser contributes only the session it was
|
|
13
|
+
* handed and the token it minted.
|
|
14
|
+
* - no vault token on the way back. `complete` answers display metadata
|
|
15
|
+
* only, and that is all the saved phase holds.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
useEffect,
|
|
19
|
+
useState,
|
|
20
|
+
type Dispatch,
|
|
21
|
+
type SetStateAction,
|
|
22
|
+
} from "react";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
detectBrand,
|
|
26
|
+
onlyDigits,
|
|
27
|
+
tokenizeForCheckout,
|
|
28
|
+
validateCardNumber,
|
|
29
|
+
validateCvv,
|
|
30
|
+
validateExpiry,
|
|
31
|
+
validateHolder,
|
|
32
|
+
type CardBrand,
|
|
33
|
+
type CardDetails,
|
|
34
|
+
type CardFieldErrors,
|
|
35
|
+
type CardTokenizationConfig,
|
|
36
|
+
} from "../card";
|
|
37
|
+
import type {
|
|
38
|
+
BuyerVaultSession,
|
|
39
|
+
VaultedCardDisplay,
|
|
40
|
+
} from "../components/checkout/transport";
|
|
41
|
+
import type { CheckoutProviderConfig } from "../components/checkout/types";
|
|
42
|
+
import type { Result } from "../result";
|
|
43
|
+
|
|
44
|
+
import { useResolvedConfig, type FlowsRuntime } from "./runtime";
|
|
45
|
+
|
|
46
|
+
const EMPTY_CARD: CardDetails = { number: "", holder: "", expiry: "", cvv: "" };
|
|
47
|
+
|
|
48
|
+
/** Where the add-card flow is, from first paint to a card on file. */
|
|
49
|
+
export type AddCardPhase =
|
|
50
|
+
| { kind: "preparing" }
|
|
51
|
+
/** `begin` refused — a state the buyer cannot fix, said plainly. */
|
|
52
|
+
| { kind: "unavailable"; message: string }
|
|
53
|
+
| { kind: "form"; session: BuyerVaultSession }
|
|
54
|
+
| { kind: "saved"; display: VaultedCardDisplay };
|
|
55
|
+
|
|
56
|
+
/** Everything the add-card view renders. A story stages one as a literal. */
|
|
57
|
+
export interface AddCardController {
|
|
58
|
+
phase: AddCardPhase;
|
|
59
|
+
card: CardDetails;
|
|
60
|
+
setCard: Dispatch<SetStateAction<CardDetails>>;
|
|
61
|
+
fieldErrors: CardFieldErrors;
|
|
62
|
+
setFieldErrors: Dispatch<SetStateAction<CardFieldErrors>>;
|
|
63
|
+
brand: CardBrand;
|
|
64
|
+
/** A tokenize + complete round trip is in flight. */
|
|
65
|
+
saving: boolean;
|
|
66
|
+
/** The refusal the buyer reads — the endpoint's own reason, form kept editable. */
|
|
67
|
+
error: string | null;
|
|
68
|
+
submit(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Word a refused `begin`. `VAULT_NOT_ENABLED` is the mount's machine-level
|
|
73
|
+
* convention (a deliberately English sentence — a host wiring gap no buyer can
|
|
74
|
+
* fix), so the factory's own pt-BR stands in for it; every other refusal
|
|
75
|
+
* (`PAYMENT_NOT_CONFIGURED`, a transport failure) already carries the pt-BR
|
|
76
|
+
* message the host's copy table worded.
|
|
77
|
+
*/
|
|
78
|
+
function beginRefusalMessage(
|
|
79
|
+
runtime: FlowsRuntime,
|
|
80
|
+
refusal: { error: string; code?: string },
|
|
81
|
+
): string {
|
|
82
|
+
return refusal.code === "VAULT_NOT_ENABLED" ? runtime.copy.addCardUnavailable : refusal.error;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The tokenization triple for THIS vault session. Provider and key come from
|
|
87
|
+
* the `begin` answer — the session's own facts. The stub grant does not travel
|
|
88
|
+
* on it: `GET /config` is the ONLY sanctioned source for `mockTokenization`
|
|
89
|
+
* (FUT-697), so it is read off the published chain entry for the session's
|
|
90
|
+
* provider, and absent that, off the config head. No config ⇒ no grant.
|
|
91
|
+
*/
|
|
92
|
+
function sessionTokenization(
|
|
93
|
+
session: BuyerVaultSession,
|
|
94
|
+
config: CheckoutProviderConfig | null,
|
|
95
|
+
): CardTokenizationConfig {
|
|
96
|
+
const link = config?.chain?.find((entry) => entry.provider === session.provider);
|
|
97
|
+
const mockTokenization =
|
|
98
|
+
link?.mockTokenization ??
|
|
99
|
+
(config?.provider === session.provider ? config.mockTokenization : false);
|
|
100
|
+
return { provider: session.provider, publicKey: session.publicKey, mockTokenization };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fetch the vault session once on mount; the phases follow the answer. */
|
|
104
|
+
function useVaultSession(runtime: FlowsRuntime): {
|
|
105
|
+
phase: AddCardPhase;
|
|
106
|
+
setPhase: Dispatch<SetStateAction<AddCardPhase>>;
|
|
107
|
+
} {
|
|
108
|
+
const [phase, setPhase] = useState<AddCardPhase>({ kind: "preparing" });
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
let active = true;
|
|
111
|
+
void runtime.client.beginVault().then((result: Result<BuyerVaultSession>) => {
|
|
112
|
+
if (!active) return;
|
|
113
|
+
if (!result.ok) {
|
|
114
|
+
setPhase({ kind: "unavailable", message: beginRefusalMessage(runtime, result) });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setPhase({ kind: "form", session: result.data });
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
active = false;
|
|
121
|
+
};
|
|
122
|
+
}, [runtime]);
|
|
123
|
+
return { phase, setPhase };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The add-card flow: begin → (buyer types) → tokenize → complete → saved.
|
|
128
|
+
*
|
|
129
|
+
* A refused `complete` sets {@link AddCardController.error} and stays on the
|
|
130
|
+
* form — the endpoint's reason is the buyer's cue to fix the card, and wiping
|
|
131
|
+
* their input to say it would be the screen working against them.
|
|
132
|
+
*/
|
|
133
|
+
export function useAddCard(
|
|
134
|
+
runtime: FlowsRuntime,
|
|
135
|
+
onSaved?: (display: VaultedCardDisplay) => void,
|
|
136
|
+
): AddCardController {
|
|
137
|
+
const { config } = useResolvedConfig(runtime);
|
|
138
|
+
const { phase, setPhase } = useVaultSession(runtime);
|
|
139
|
+
const [card, setCard] = useState<CardDetails>(EMPTY_CARD);
|
|
140
|
+
const [fieldErrors, setFieldErrors] = useState<CardFieldErrors>({});
|
|
141
|
+
const [saving, setSaving] = useState(false);
|
|
142
|
+
const [error, setError] = useState<string | null>(null);
|
|
143
|
+
|
|
144
|
+
const brand = detectBrand(onlyDigits(card.number));
|
|
145
|
+
|
|
146
|
+
const validate = (): CardFieldErrors => ({
|
|
147
|
+
number: validateCardNumber(card.number),
|
|
148
|
+
holder: validateHolder(card.holder),
|
|
149
|
+
expiry: validateExpiry(card.expiry),
|
|
150
|
+
cvv: validateCvv(card.cvv, brand),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const submit = async (): Promise<void> => {
|
|
154
|
+
if (phase.kind !== "form" || saving) return;
|
|
155
|
+
setError(null);
|
|
156
|
+
const errors = validate();
|
|
157
|
+
setFieldErrors(errors);
|
|
158
|
+
if (Object.values(errors).some(Boolean)) return;
|
|
159
|
+
|
|
160
|
+
setSaving(true);
|
|
161
|
+
const minted = await tokenizeForCheckout(card, sessionTokenization(phase.session, config));
|
|
162
|
+
if (!minted.ok) {
|
|
163
|
+
setError(minted.error);
|
|
164
|
+
setSaving(false);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// The browser's two legitimate facts, and nothing else: the session it is
|
|
168
|
+
// completing and the instrument it minted. Ownership rides server-side.
|
|
169
|
+
const completed = await runtime.client.completeVault({
|
|
170
|
+
...(phase.session.sessionId ? { sessionId: phase.session.sessionId } : {}),
|
|
171
|
+
token: minted.data.token,
|
|
172
|
+
});
|
|
173
|
+
setSaving(false);
|
|
174
|
+
if (!completed.ok) {
|
|
175
|
+
setError(completed.error);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
setPhase({ kind: "saved", display: completed.data });
|
|
179
|
+
onSaved?.(completed.data);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { phase, card, setCard, fieldErrors, setFieldErrors, brand, saving, error, submit };
|
|
183
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -78,8 +78,11 @@ export {
|
|
|
78
78
|
export {
|
|
79
79
|
createCheckoutClient,
|
|
80
80
|
DEFAULT_CHECKOUT_BASE_URL,
|
|
81
|
+
type BuyerVaultSession,
|
|
81
82
|
type CheckoutClient,
|
|
82
83
|
type CheckoutTransport,
|
|
84
|
+
type CompleteVaultInput,
|
|
85
|
+
type VaultedCardDisplay,
|
|
83
86
|
} from './components/checkout/transport';
|
|
84
87
|
export { type CheckoutHostPorts } from './components/checkout/use-checkout-controller';
|
|
85
88
|
export { PaymentsUnavailable } from './components/checkout/payments-unavailable';
|