@12-apps/payments-frontend 3.17.0 → 3.18.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.17.0",
3
+ "version": "3.18.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -22,7 +22,7 @@
22
22
  "storybook:build": "storybook build"
23
23
  },
24
24
  "dependencies": {
25
- "@12-apps/payments-backend": "^4.22.0",
25
+ "@12-apps/payments-backend": "^4.25.0",
26
26
  "react-qr-code": "^2.2.0"
27
27
  },
28
28
  "peerDependencies": {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The ACTIVATION step's whole public surface — the two protocols, and the
3
+ * screens that render them (FUT-463, FUT-763, FUT-764).
4
+ *
5
+ * A file of its own rather than another block on the root barrel, which is at
6
+ * its size cap: a barrel that has to be trimmed to accept a new capability is
7
+ * one that starts hiding capabilities to stay small. Every name below is
8
+ * re-exported from the root unchanged, so nothing an adopter imports moves.
9
+ */
10
+ // ---------------------------------------------------------------------------
11
+ // The REDIRECT ACTIVATION protocol (FUT-463, packaged by FUT-763) — proving a
12
+ // connection can charge, for a provider whose payer pays on its own page.
13
+ //
14
+ // `renderVerification` above stays what it was: the package decides where the
15
+ // step appears and the host owns the screen. What moved is the protocol behind
16
+ // it — resume-on-mount, the return trip's ids, the refusal/expiry/transport
17
+ // distinctions, the bounded wait. Every one of those was learned from a payment
18
+ // that went wrong, and no second host should have to learn them again.
19
+ // ---------------------------------------------------------------------------
20
+ export {
21
+ useRedirectActivation,
22
+ type RedirectActivation,
23
+ type RedirectActivationOptions,
24
+ } from './use-redirect-activation';
25
+ export { type RedirectActivationCopy } from './copy';
26
+ export {
27
+ creationFailure,
28
+ postActivation,
29
+ refusedByProvider,
30
+ settleActivationPoll,
31
+ type ActivationClock,
32
+ type ActivationPendingBody,
33
+ type ActivationPollBody,
34
+ type RedirectActivationState,
35
+ type SettlePollIo,
36
+ } from './redirect-state';
37
+ export {
38
+ clearReturnedSettlement,
39
+ takeReturnedSettlement,
40
+ RETURNED_SETTLEMENT_KEY,
41
+ } from './returned-settlement';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
45
+ // can charge, for a provider whose payer pays HERE.
46
+ //
47
+ // A connection is not a capability: a completed grant says the owner authorized
48
+ // us, not that the account can take money. The owner's own card goes through
49
+ // the SAME path a shopper's does — same fields, same validation, same
50
+ // browser-side encryption — for one cent, refunded immediately.
51
+ //
52
+ // The sibling of `useRedirectActivation` for the other half of the same step.
53
+ // As there, the SCREEN stays the host's.
54
+ // ---------------------------------------------------------------------------
55
+ export {
56
+ useActivationCharge,
57
+ type ActivationCharge,
58
+ type ActivationChargeOptions,
59
+ type ActivationChargeState,
60
+ } from './use-activation-charge';
61
+ export { type ActivationChargeCopy } from './charge-copy';
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // The ACTIVATION STEP's SCREENS (FUT-764) — the six settled outcomes, the
65
+ // outstanding-payment panel with its link fallback, the two flows and the
66
+ // router between them.
67
+ //
68
+ // The protocol hooks above shipped without them, leaving `renderVerification`
69
+ // as "the host owns the screen". That reads as a boundary and is not one: the
70
+ // screens are a rendering of THIS package's state machines, and the origin
71
+ // host's copies carried a paragraph each about a real payment that went wrong —
72
+ // an owner who paid four times, a dead end blaming a store for a key that was
73
+ // never going to exist, a refusal wearing another failure's clothes. None of
74
+ // that is host knowledge, and a second adopter deriving it again would be
75
+ // deriving it from the same payments.
76
+ //
77
+ // The SENTENCES stay the host's, required and defaultless, as everywhere here.
78
+ // ---------------------------------------------------------------------------
79
+ export {
80
+ createActivationStep,
81
+ type ActivationStepConfig,
82
+ type ActivationStepProps,
83
+ type CardSurface,
84
+ type ActivationActionCopy,
85
+ type ActivationAwaitingCopy,
86
+ type ActivationIntroCopy,
87
+ type ActivationOutcomeCopy,
88
+ type ActivationStepCopy,
89
+ type ActivationTaxIdCopy,
90
+ } from './screens';
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import type { ComponentType, JSX } from 'react';
4
+
5
+ import { tokenizerFor } from '../../card/tokenize';
6
+
7
+ import { CardVerification, type CardSurface } from './card-verification';
8
+ import { ActivationCopyProvider } from './copy-context';
9
+ import type { ActivationStepCopy } from './copy';
10
+ import { ProvenState } from './states';
11
+ import { RedirectVerification } from './redirect-verification';
12
+
13
+ /**
14
+ * Step 3 of connecting a provider — "connected" and "can charge" are different
15
+ * facts, and this is the one that proves the second (FUT-463, screens packaged
16
+ * by FUT-764's burn-down).
17
+ *
18
+ * `PaymentProviderSettings` decides WHERE this step appears; what it looks like
19
+ * and how it behaves is here. The split it replaces put the protocol in this
20
+ * package and the whole screen in the host, which meant every adopter rewrote
21
+ * six outcome panels, a polling display, a link fallback and a router between
22
+ * two flows — and the origin host's copies of them carry a paragraph each about
23
+ * a real payment that went wrong. None of that is host knowledge. The
24
+ * SENTENCES are, and they stay a required, defaultless port.
25
+ *
26
+ * What the host still answers:
27
+ *
28
+ * - `verifyChargeUrl` — its own endpoint. The route shape is the host's.
29
+ * - `copy` — every word, in the owner's language.
30
+ * - `formatAmount` — how this product writes money.
31
+ * - `CardSurface` — its design system and card words, for the card flow's
32
+ * fields, so the owner meets the SAME form their shoppers will.
33
+ * - `validateTaxId` — the host's validator, already bound to its own words.
34
+ */
35
+
36
+ /** What the settings surface hands the step, plus what only the host knows. */
37
+ export interface ActivationStepProps {
38
+ provider: string;
39
+ /** The provider's human name — the refusal screens have to say who refused. */
40
+ displayName: string;
41
+ /** A stored connection exists — there is an account to charge through. */
42
+ connected: boolean;
43
+ /** A real charge already landed — say so, and charge nothing. */
44
+ proven: boolean;
45
+ /** An earlier setup step is unconfirmed: withhold the pay button, nothing else. */
46
+ blocked: boolean;
47
+ /** The walkthrough is on an earlier step: show only what has already settled. */
48
+ hidden: boolean;
49
+ onVerified: () => void;
50
+ /** The provider refused to mint a link — reopen the step that explains why. */
51
+ onSetupIncomplete: () => void;
52
+ /** Who is paying: the signed-in owner, read from the host's own session. */
53
+ ownerEmail: string;
54
+ /** The storefront this connection now takes money for. */
55
+ storeUrl: string;
56
+ /** Where the owner goes once this is done: the failover chain. */
57
+ onProviderOrder: () => void;
58
+ }
59
+
60
+ export interface ActivationStepConfig {
61
+ /** Where this host mints, polls and discards the activation charge. */
62
+ verifyChargeUrl: (provider: string) => string;
63
+ /** Every sentence the step renders. Required, and there is no default. */
64
+ copy: ActivationStepCopy;
65
+ /** How this product writes an amount in cents. */
66
+ formatAmount: (cents: number) => string;
67
+ /** The host's providers around the card fields — its design system, its words. */
68
+ CardSurface: CardSurface;
69
+ /** The host's tax-id validator, already bound to its own refusals. */
70
+ validateTaxId: (value: string) => string | undefined;
71
+ /**
72
+ * Where the return trip's ids are parked.
73
+ *
74
+ * Optional, and a host that has shipped this flow before should pass the name
75
+ * it already used. An owner can be on the provider's site paying RIGHT NOW
76
+ * across a deploy: their ids are in `sessionStorage` under the old key, and
77
+ * reading a different one on the way back would find nothing and offer to
78
+ * mint a charge for a payment they had just made.
79
+ */
80
+ storageKey?: string;
81
+ }
82
+
83
+ /**
84
+ * Bind the step to one host, once.
85
+ *
86
+ * A factory rather than a component with a dozen props, for the reason every
87
+ * mount in this package is one: the returned component's IDENTITY has to be
88
+ * stable, and a host composing it inline inside `renderVerification` would
89
+ * remount the whole step — and re-fetch, and lose a half-typed card — on every
90
+ * render of the page above it.
91
+ */
92
+ export function createActivationStep(
93
+ config: ActivationStepConfig,
94
+ ): ComponentType<ActivationStepProps> {
95
+ function ActivationStep(props: ActivationStepProps): JSX.Element | null {
96
+ return (
97
+ <ActivationCopyProvider copy={config.copy}>
98
+ <ActivationBody {...props} config={config} />
99
+ </ActivationCopyProvider>
100
+ );
101
+ }
102
+ ActivationStep.displayName = 'ActivationStep';
103
+ return ActivationStep;
104
+ }
105
+
106
+ /** The router between the two flows, inside the copy provider. */
107
+ function ActivationBody({
108
+ config,
109
+ provider,
110
+ displayName,
111
+ connected,
112
+ proven,
113
+ blocked,
114
+ hidden,
115
+ onVerified,
116
+ onSetupIncomplete,
117
+ ownerEmail,
118
+ storeUrl,
119
+ onProviderOrder,
120
+ }: ActivationStepProps & { config: ActivationStepConfig }): JSX.Element | null {
121
+ // Nothing to verify until the account is connected: steps 1 and 2 come first.
122
+ if (!connected) return null;
123
+
124
+ if (proven) {
125
+ return <ProvenState storeUrl={storeUrl} onProviderOrder={onProviderOrder} />;
126
+ }
127
+
128
+ /**
129
+ * A card form this provider could never satisfy is worse than no form.
130
+ *
131
+ * A REDIRECT provider's buyer pays on ITS page, so there is no card to
132
+ * tokenize here and no public key to do it with. Rendering the fields anyway
133
+ * produced a dead end that blamed the store — "the card public key is not
134
+ * available for this store, reconnect the provider" — an instruction that
135
+ * cannot work, for a key that was never going to exist.
136
+ *
137
+ * It still gets a REAL charge, just minted as a link instead.
138
+ */
139
+ if (!tokenizerFor(provider)) {
140
+ return (
141
+ <RedirectVerification
142
+ verifyChargeUrl={config.verifyChargeUrl(provider)}
143
+ displayName={displayName}
144
+ blocked={blocked}
145
+ hidden={hidden}
146
+ storageKey={config.storageKey}
147
+ formatAmount={config.formatAmount}
148
+ onVerified={onVerified}
149
+ onSetupIncomplete={onSetupIncomplete}
150
+ />
151
+ );
152
+ }
153
+
154
+ // The card flow settles synchronously, so it has nothing outstanding to
155
+ // report from an earlier step — hiding it is still correct there.
156
+ if (hidden) return null;
157
+ return (
158
+ <CardVerification
159
+ verifyChargeUrl={config.verifyChargeUrl(provider)}
160
+ provider={provider}
161
+ ownerEmail={ownerEmail}
162
+ onVerified={onVerified}
163
+ CardSurface={config.CardSurface}
164
+ validateTaxId={config.validateTaxId}
165
+ formatAmount={config.formatAmount}
166
+ />
167
+ );
168
+ }
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, Stack, Typography } from '@mui/material';
4
+ import { useEffect, useState, type JSX } from 'react';
5
+
6
+ import { BTN_PRIMARY_SX, BTN_SECONDARY_SX, LINKISH_SX, T } from '../../components/panel-tokens';
7
+
8
+ import { useActivationCopy } from './copy-context';
9
+ import { Notice } from './notice';
10
+
11
+ /**
12
+ * How long ago the provider was last asked, ticking.
13
+ *
14
+ * A second's resolution, from a value the POLL owns — not a counter this
15
+ * component increments, which would drift away from the requests actually being
16
+ * made and end up reassuring the owner about polling that had stopped.
17
+ */
18
+ function useSecondsSince(timestamp: number): number | null {
19
+ const [now, setNow] = useState(() => Date.now());
20
+ useEffect(() => {
21
+ // Through the setter, never a closed-over binding — a counter that carried
22
+ // its own previous value would keep counting after the poll stopped.
23
+ const timer = setInterval(() => setNow(Date.now()), 1000);
24
+ return () => clearInterval(timer);
25
+ }, []);
26
+ if (!timestamp) return null;
27
+ return Math.max(0, Math.round((now - timestamp) / 1000));
28
+ }
29
+
30
+ /** "3s ago", or nothing at all before the first answer. */
31
+ function LastChecked({ lastCheckedAt }: { lastCheckedAt: number }): JSX.Element | null {
32
+ const { awaiting } = useActivationCopy();
33
+ const seconds = useSecondsSince(lastCheckedAt);
34
+ if (seconds === null) return null;
35
+ return (
36
+ <Typography
37
+ sx={{ fontSize: '11.5px', color: T.ink3 }}
38
+ data-testid="verify-charge-last-checked"
39
+ >
40
+ {awaiting.lastChecked(seconds)}
41
+ </Typography>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * The return-trip variant. There is nothing to open and nothing to pay —
47
+ * showing a pay button to someone who has just paid is how one owner,
48
+ * reasonably reading it as "it did not work", paid four times.
49
+ */
50
+ function ConfirmingReturn({
51
+ lastCheckedAt,
52
+ onCheckNow,
53
+ }: {
54
+ lastCheckedAt: number;
55
+ onCheckNow: () => Promise<void>;
56
+ }): JSX.Element {
57
+ const { awaiting, actions } = useActivationCopy();
58
+ return (
59
+ <Stack spacing={1} data-testid="verify-charge-confirming">
60
+ <Notice tone="info" title={awaiting.receivedTitle} description={awaiting.receivedBody} />
61
+ <LastChecked lastCheckedAt={lastCheckedAt} />
62
+ <Stack direction="row">
63
+ <Button sx={LINKISH_SX} onClick={() => void onCheckNow()} data-testid="verify-charge-check-now">
64
+ {actions.checkNow}
65
+ </Button>
66
+ </Stack>
67
+ </Stack>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * The payment link: opened, copied, or read — but no longer SHOUTED.
73
+ *
74
+ * It used to be printed in full underneath, and the reason was sound: a blocked
75
+ * popup, a closed tab or a phone in the owner's hand all end at "I need the
76
+ * address", and a button that only knows how to `window.open` serves none of
77
+ * them. But a hosted-checkout URL can be ~400 characters of opaque blob, so the
78
+ * longest thing on the screen was the one thing nobody reads — directly above
79
+ * the sentence saying a payment was outstanding.
80
+ *
81
+ * So it is folded away rather than removed. Copy handles the ordinary case in
82
+ * one click; "show the link" still yields a real, selectable anchor, which is
83
+ * what a browser with no clipboard permission (and every non-secure origin has
84
+ * none) falls back to. Dropping the address entirely would have re-created the
85
+ * dead end with a tidier layout.
86
+ */
87
+ function LinkActions({ checkoutUrl }: { checkoutUrl: string }): JSX.Element {
88
+ const { awaiting } = useActivationCopy();
89
+ const [copied, setCopied] = useState(false);
90
+ const [shown, setShown] = useState(false);
91
+ return (
92
+ <Stack spacing={1}>
93
+ <Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: 'wrap' }}>
94
+ <Button
95
+ sx={BTN_PRIMARY_SX}
96
+ onClick={() => window.open(checkoutUrl, '_blank', 'noopener')}
97
+ data-testid="verify-charge-open-link"
98
+ >
99
+ {awaiting.openPaymentPage}
100
+ </Button>
101
+ <Button
102
+ sx={BTN_SECONDARY_SX}
103
+ onClick={() => {
104
+ void navigator.clipboard
105
+ ?.writeText(checkoutUrl)
106
+ .then(() => setCopied(true))
107
+ // No clipboard permission: reveal the anchor instead, which is
108
+ // the fallback that actually works on a non-secure origin.
109
+ .catch(() => setShown(true));
110
+ }}
111
+ data-testid="verify-charge-copy-link"
112
+ >
113
+ {copied ? awaiting.linkCopied : awaiting.copyLink}
114
+ </Button>
115
+ <Button
116
+ sx={LINKISH_SX}
117
+ onClick={() => setShown((open) => !open)}
118
+ data-testid="verify-charge-toggle-link"
119
+ >
120
+ {shown ? awaiting.hideLink : awaiting.showLink}
121
+ </Button>
122
+ </Stack>
123
+ {shown ? (
124
+ <Box
125
+ component="a"
126
+ href={checkoutUrl}
127
+ target="_blank"
128
+ rel="noopener noreferrer"
129
+ data-testid="verify-charge-checkout-url"
130
+ sx={{
131
+ display: 'block',
132
+ fontFamily: T.mono,
133
+ fontSize: '12px',
134
+ wordBreak: 'break-all',
135
+ userSelect: 'text',
136
+ color: T.ink2,
137
+ p: '10px 12px',
138
+ borderRadius: '8px',
139
+ background: T.bg2,
140
+ border: `1px solid ${T.line2}`,
141
+ }}
142
+ >
143
+ {checkoutUrl}
144
+ </Box>
145
+ ) : null}
146
+ </Stack>
147
+ );
148
+ }
149
+
150
+ /**
151
+ * A payment ATTEMPT was refused while this link stayed live.
152
+ *
153
+ * Rendered inside the waiting panel rather than replacing it, because the
154
+ * charge is still there to be paid and the fix is another method on the SAME
155
+ * link. Replacing the panel would take the link away and offer to generate a
156
+ * second real charge for a first one nobody cancelled.
157
+ */
158
+ function DeclinedNotice({ message }: { message: string }): JSX.Element {
159
+ const { awaiting } = useActivationCopy();
160
+ return (
161
+ <Notice
162
+ tone="warn"
163
+ title={awaiting.declinedTitle}
164
+ description={message}
165
+ dataTestId="verify-charge-declined"
166
+ />
167
+ );
168
+ }
169
+
170
+ /**
171
+ * The link is live and the owner is paying it on the provider's site (FUT-463).
172
+ *
173
+ * The tab was already opened for them, from inside the click that generated the
174
+ * charge (see `useRedirectActivation`) — pressing the pay button IS the request
175
+ * to go and pay, and making them hunt for a second button afterwards was the
176
+ * extra step this screen exists to remove. So this panel is the fallback, and
177
+ * it has to be a real one.
178
+ *
179
+ * "I already paid — check now" exists because the poll runs every few seconds
180
+ * and someone who has just paid should not have to wonder whether the screen
181
+ * noticed. It asks the provider, exactly as the timer does.
182
+ */
183
+ export function AwaitingPayment({
184
+ checkoutUrl,
185
+ amountLabel,
186
+ declined,
187
+ lastCheckedAt,
188
+ onCheckNow,
189
+ }: {
190
+ /** Null on a return trip: the owner has PAID and we are only confirming. */
191
+ checkoutUrl: string | null;
192
+ amountLabel: string;
193
+ /** A refused attempt on this still-payable charge, in the owner's language. */
194
+ declined?: string;
195
+ lastCheckedAt: number;
196
+ onCheckNow: () => Promise<void>;
197
+ }): JSX.Element {
198
+ const { awaiting, actions } = useActivationCopy();
199
+ if (!checkoutUrl) {
200
+ return <ConfirmingReturn lastCheckedAt={lastCheckedAt} onCheckNow={onCheckNow} />;
201
+ }
202
+
203
+ return (
204
+ <Stack spacing={1} data-testid="verify-charge-awaiting">
205
+ {declined ? <DeclinedNotice message={declined} /> : null}
206
+ {/*
207
+ The waiting title leads, because that is the screen's STATE and it is
208
+ what the owner came back to check. The amount and the "we opened a tab"
209
+ explanation are context for it; as the headline they described a thing
210
+ that had already happened and left the owner to infer that the screen
211
+ was still watching.
212
+ */}
213
+ <Notice
214
+ tone="info"
215
+ title={awaiting.waitingTitle}
216
+ description={awaiting.waitingBody(amountLabel)}
217
+ />
218
+ <LinkActions checkoutUrl={checkoutUrl} />
219
+ <Stack direction="row" spacing={1} alignItems="center">
220
+ <LastChecked lastCheckedAt={lastCheckedAt} />
221
+ <Button sx={LINKISH_SX} onClick={() => void onCheckNow()} data-testid="verify-charge-check-now">
222
+ {actions.alreadyPaidCheckNow}
223
+ </Button>
224
+ </Stack>
225
+ </Stack>
226
+ );
227
+ }
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { Button, Stack, Typography } from '@mui/material';
4
+ import type { ComponentType, JSX, ReactNode } from 'react';
5
+
6
+ import { NewCardForm } from '../../card/fields';
7
+ import { detectBrand, onlyDigits } from '../../card/format';
8
+ import { formatCpf } from '../../card/cpf';
9
+ import { useCheckoutComponents } from '../../components/checkout/ui';
10
+ import { BTN_PRIMARY_SX, T } from '../../components/panel-tokens';
11
+ import { useActivationCharge, type ActivationCharge } from '../use-activation-charge';
12
+
13
+ import { useActivationCopy } from './copy-context';
14
+ import { StepPanel } from './notice';
15
+ import { FailedState, PassedState } from './states';
16
+
17
+ /**
18
+ * Step 3 for a provider whose payer pays HERE: tokenize, charge, settle.
19
+ *
20
+ * This is the SAME form a shopper fills in at checkout — the package's own
21
+ * `NewCardForm`, rendered through the host's slot binding, not a second form
22
+ * shaped almost like it. A verification that exercised a different form, a
23
+ * different validator or a different tokenizer would prove something about that
24
+ * path instead of the one that takes real money.
25
+ *
26
+ * The PROTOCOL is `useActivationCharge`'s: validate locally before anything
27
+ * leaves the browser, require a real public key, send only the token, pass only
28
+ * on the server's own answer, and keep the card typed in on a refusal.
29
+ */
30
+
31
+ /** The host's card-entry providers — its design system and its card words. */
32
+ export type CardSurface = ComponentType<{ children: ReactNode }>;
33
+
34
+ /** The card + tax-id fields and the one button that runs the charge. */
35
+ function ChargeForm({
36
+ verification,
37
+ amountLabel,
38
+ validateTaxId,
39
+ }: {
40
+ verification: ActivationCharge;
41
+ amountLabel: string | null;
42
+ validateTaxId: (value: string) => string | undefined;
43
+ }): JSX.Element {
44
+ const { taxId, actions } = useActivationCopy();
45
+ const { Input } = useCheckoutComponents();
46
+ const { card, setCard, fieldErrors, setFieldErrors, cpf, setCpf, cpfError, state } = verification;
47
+ const submitting = state.kind === 'submitting';
48
+
49
+ return (
50
+ <Stack spacing={2} data-testid="verify-charge-form">
51
+ <NewCardForm
52
+ card={card}
53
+ fieldErrors={fieldErrors}
54
+ brand={detectBrand(onlyDigits(card.number))}
55
+ setCard={setCard}
56
+ setFieldErrors={setFieldErrors}
57
+ />
58
+ <Input
59
+ label={taxId.label}
60
+ type="text"
61
+ inputMode="numeric"
62
+ variant="outlined"
63
+ size="md"
64
+ fullWidth
65
+ placeholder={taxId.placeholder}
66
+ value={formatCpf(cpf)}
67
+ error={Boolean(cpfError)}
68
+ helperText={cpfError ?? taxId.hint}
69
+ onChange={(event) => setCpf(onlyDigits(event.target.value))}
70
+ onBlur={() => validateTaxId(cpf)}
71
+ data-testid="verify-charge-cpf"
72
+ />
73
+ <Button
74
+ sx={{ ...BTN_PRIMARY_SX, width: '100%' }}
75
+ disabled={submitting}
76
+ onClick={() => void verification.submit()}
77
+ data-testid="verify-charge-submit"
78
+ >
79
+ {actions.chargeAndActivate(amountLabel)}
80
+ </Button>
81
+ </Stack>
82
+ );
83
+ }
84
+
85
+ /** What a settled card charge says — the only two ends this flow has. */
86
+ function CardOutcome({
87
+ state,
88
+ amountLabel,
89
+ onRetry,
90
+ }: {
91
+ state: ActivationCharge['state'];
92
+ amountLabel: string | null;
93
+ onRetry: () => void;
94
+ }): JSX.Element | null {
95
+ if (state.kind === 'passed') {
96
+ return <PassedState amountLabel={amountLabel} refunded={state.refunded} onRetry={onRetry} />;
97
+ }
98
+ if (state.kind === 'failed') {
99
+ return (
100
+ <FailedState
101
+ reason={state.reason}
102
+ providerMessage={state.providerMessage}
103
+ onRetry={onRetry}
104
+ />
105
+ );
106
+ }
107
+ return null;
108
+ }
109
+
110
+ export function CardVerification({
111
+ verifyChargeUrl,
112
+ provider,
113
+ ownerEmail,
114
+ onVerified,
115
+ CardSurface: Surface,
116
+ validateTaxId,
117
+ formatAmount,
118
+ }: {
119
+ verifyChargeUrl: string;
120
+ provider: string;
121
+ /** Who is paying — the signed-in owner. The host reads its own session. */
122
+ ownerEmail: string;
123
+ onVerified: () => void;
124
+ CardSurface: CardSurface;
125
+ validateTaxId: (value: string) => string | undefined;
126
+ formatAmount: (cents: number) => string;
127
+ }): JSX.Element {
128
+ const copy = useActivationCopy();
129
+ const verification = useActivationCharge({
130
+ verifyChargeUrl,
131
+ provider,
132
+ email: ownerEmail,
133
+ onVerified,
134
+ copy: copy.charge,
135
+ });
136
+ // The verification endpoint answers the amount alongside the store's card
137
+ // key, and `useActivationCharge` reads both out of that one body — where this
138
+ // screen's origin asked the same URL a second time for the amount alone.
139
+ //
140
+ // `null` is the window before it has answered. Nothing guesses a cent there,
141
+ // because not every provider will take one, so the sentences below simply do
142
+ // not name an amount until there is one to name.
143
+ const amountLabel =
144
+ verification.amountCents === null ? null : formatAmount(verification.amountCents);
145
+ const settled = verification.state.kind === 'passed' || verification.state.kind === 'failed';
146
+
147
+ return (
148
+ <StepPanel dataTestId="verify-charge">
149
+ <Stack spacing={0.5}>
150
+ <Typography sx={{ fontSize: '14px', fontWeight: 650, color: T.ink }} component="h2">
151
+ {copy.intro.title}
152
+ </Typography>
153
+ <Typography sx={{ fontSize: '12.5px', color: T.ink3, lineHeight: 1.5 }}>
154
+ {copy.intro.cardBody(amountLabel)}
155
+ </Typography>
156
+ </Stack>
157
+
158
+ <CardOutcome state={verification.state} amountLabel={amountLabel} onRetry={verification.reset} />
159
+
160
+ {/*
161
+ No standing "provider is active" banner here.
162
+
163
+ It was rendered from `enabled` alone, which is a claim about a stored
164
+ flag and not about whether money moves — so a store whose charges were
165
+ all refused with `403 ACCESS_DENIED` was greeted with "you are
166
+ receiving through this provider" every time it reloaded, directly above
167
+ the form that had just disproven it. What a passing charge says is said
168
+ by `PassedState`, in this session, about a charge that actually
169
+ happened.
170
+ */}
171
+ {settled ? null : (
172
+ <Surface>
173
+ <ChargeForm
174
+ verification={verification}
175
+ amountLabel={amountLabel}
176
+ validateTaxId={validateTaxId}
177
+ />
178
+ </Surface>
179
+ )}
180
+ </StepPanel>
181
+ );
182
+ }