@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 +2 -2
- package/src/activation/public.ts +90 -0
- package/src/activation/screens/activation-step.tsx +168 -0
- package/src/activation/screens/awaiting-payment.tsx +227 -0
- package/src/activation/screens/card-verification.tsx +182 -0
- package/src/activation/screens/copy-context.tsx +43 -0
- package/src/activation/screens/copy.ts +150 -0
- package/src/activation/screens/index.ts +10 -0
- package/src/activation/screens/notice.tsx +111 -0
- package/src/activation/screens/redirect-verification.tsx +249 -0
- package/src/activation/screens/states.tsx +244 -0
- package/src/activation/screens/use-verification-amount.ts +42 -0
- package/src/index.ts +27 -49
- package/src/ledger/index.ts +17 -0
- package/src/ledger/query.ts +70 -0
- package/src/ledger/rows.ts +85 -0
- package/src/ledger/wire.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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
|
+
}
|