@12-apps/payments-frontend 3.17.0 → 3.18.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/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 +81 -0
- package/src/ledger/rows.ts +85 -0
- package/src/ledger/wire.ts +58 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type JSX, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ActivationStepCopy } from './copy';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Step 3's words, for the dozen panels that render them.
|
|
9
|
+
*
|
|
10
|
+
* A CONTEXT rather than a prop, for the same reason `PaymentsSettingsCopy` is
|
|
11
|
+
* one: this step is a tree of small pieces — six settled states, the waiting
|
|
12
|
+
* panel, the link actions, two flows and the form between them — and threading
|
|
13
|
+
* one object through all of them as props is how a copy port comes to exist,
|
|
14
|
+
* be required, and go unread. It stays a single REQUIRED field at the mount
|
|
15
|
+
* (`createActivationStep`), which is the only place a host has to answer.
|
|
16
|
+
*/
|
|
17
|
+
const ActivationCopyContext = createContext<ActivationStepCopy | null>(null);
|
|
18
|
+
|
|
19
|
+
export function ActivationCopyProvider({
|
|
20
|
+
copy,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
copy: ActivationStepCopy;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}): JSX.Element {
|
|
26
|
+
return <ActivationCopyContext.Provider value={copy}>{children}</ActivationCopyContext.Provider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The words this step renders — THROWS outside a provider rather than falling
|
|
31
|
+
* back.
|
|
32
|
+
*
|
|
33
|
+
* A fallback could only be the origin host's Portuguese, handed silently to the
|
|
34
|
+
* next adopter's store owner. Failing at the mount is the point: it is the one
|
|
35
|
+
* moment a host can still be told it forgot.
|
|
36
|
+
*/
|
|
37
|
+
export function useActivationCopy(): ActivationStepCopy {
|
|
38
|
+
const copy = useContext(ActivationCopyContext);
|
|
39
|
+
if (!copy) {
|
|
40
|
+
throw new Error('useActivationCopy must be rendered inside an <ActivationCopyProvider>');
|
|
41
|
+
}
|
|
42
|
+
return copy;
|
|
43
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ActivationChargeCopy } from '../charge-copy';
|
|
2
|
+
import type { RedirectActivationCopy } from '../copy';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every sentence step 3 puts in front of a store owner (FUT-764 burn-down).
|
|
6
|
+
*
|
|
7
|
+
* Required, with no defaults — this package's doctrine, stated in
|
|
8
|
+
* `checkout/view-copy.ts` and again in `settings-copy.ts`: a default in the
|
|
9
|
+
* origin host's language reads as finished to the next host right up until an
|
|
10
|
+
* owner sees it.
|
|
11
|
+
*
|
|
12
|
+
* The split is the one the whole port draws. WHICH outcome the activation
|
|
13
|
+
* charge reached — refused at creation, refused on payment, unreachable,
|
|
14
|
+
* expired, outstanding, already proven — is knowledge of the activation
|
|
15
|
+
* lifecycle and it stays here. The words are the host's.
|
|
16
|
+
*
|
|
17
|
+
* ## What is NOT in here
|
|
18
|
+
*
|
|
19
|
+
* The provider's own display name: it arrives as `displayName` and is
|
|
20
|
+
* interpolated by the functions below, because "InfinitePay" is InfinitePay's
|
|
21
|
+
* name in every language.
|
|
22
|
+
*
|
|
23
|
+
* The AMOUNT. Every sentence that names one takes an already-formatted string,
|
|
24
|
+
* because how a host writes money is a decision it has made elsewhere — and
|
|
25
|
+
* because the figure is the provider's, not this package's to round.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The step's heading and the two sentences under it, per flow. */
|
|
29
|
+
export interface ActivationIntroCopy {
|
|
30
|
+
/** The heading both flows carry — "Passo 3 · …". */
|
|
31
|
+
readonly title: string;
|
|
32
|
+
/**
|
|
33
|
+
* The card flow's lead: a test charge on the owner's own card, refunded.
|
|
34
|
+
*
|
|
35
|
+
* Takes the formatted amount or `null` — the window before the endpoint has
|
|
36
|
+
* priced the charge. A package that guessed a cent there would have the
|
|
37
|
+
* screen promise one figure while the button charged another, which is the
|
|
38
|
+
* exact lie this step exists to remove.
|
|
39
|
+
*/
|
|
40
|
+
cardBody(amountLabel: string | null): string;
|
|
41
|
+
/** The redirect flow's first line: what is about to be charged. */
|
|
42
|
+
realCharge(amountLabel: string): string;
|
|
43
|
+
/** Its second: whose money moves, and where it lands. */
|
|
44
|
+
payingYourself(amountLabel: string): string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The buttons that start a charge, and the one that re-runs a settled test. */
|
|
48
|
+
export interface ActivationActionCopy {
|
|
49
|
+
/** The card flow's submit — takes the formatted amount, or `null`. */
|
|
50
|
+
chargeAndActivate(amountLabel: string | null): string;
|
|
51
|
+
/** The redirect flow's submit, which always names an amount. */
|
|
52
|
+
payAndActivate(amountLabel: string): string;
|
|
53
|
+
/** Re-run the activation TEST — not "retry a failed read". */
|
|
54
|
+
readonly testAgain: string;
|
|
55
|
+
/** The retry button on the settled-and-refused state. */
|
|
56
|
+
readonly retry: string;
|
|
57
|
+
/** "Try again" for an outage, which is a different act from `retry`. */
|
|
58
|
+
readonly tryAgain: string;
|
|
59
|
+
/** Clear a refused creation and return the step to its start. */
|
|
60
|
+
readonly restart: string;
|
|
61
|
+
/** Mint a replacement for a link whose window elapsed. */
|
|
62
|
+
readonly generateNewCharge: string;
|
|
63
|
+
/** Ask the provider right now instead of waiting for the next tick. */
|
|
64
|
+
readonly checkNow: string;
|
|
65
|
+
/** The same, worded for someone who has just paid. */
|
|
66
|
+
readonly alreadyPaidCheckNow: string;
|
|
67
|
+
/** Where the owner goes next: the order providers are tried in. */
|
|
68
|
+
readonly setProviderOrder: string;
|
|
69
|
+
/** And the shop this connection now takes money for. */
|
|
70
|
+
readonly seePublishedStore: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The outstanding-payment panel, and the link it is waiting on. */
|
|
74
|
+
export interface ActivationAwaitingCopy {
|
|
75
|
+
/** The return trip: the owner has paid and we are only confirming. */
|
|
76
|
+
readonly receivedTitle: string;
|
|
77
|
+
readonly receivedBody: string;
|
|
78
|
+
/** A refused attempt on a charge that is still payable. */
|
|
79
|
+
readonly declinedTitle: string;
|
|
80
|
+
/** The live link's own panel. */
|
|
81
|
+
readonly waitingTitle: string;
|
|
82
|
+
waitingBody(amountLabel: string): string;
|
|
83
|
+
/** How long ago the provider was last asked, in seconds. */
|
|
84
|
+
lastChecked(seconds: number): string;
|
|
85
|
+
readonly openPaymentPage: string;
|
|
86
|
+
readonly copyLink: string;
|
|
87
|
+
readonly linkCopied: string;
|
|
88
|
+
readonly showLink: string;
|
|
89
|
+
readonly hideLink: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The settled outcomes — where this screen earns its keep. */
|
|
93
|
+
export interface ActivationOutcomeCopy {
|
|
94
|
+
/** Passed, with the refund already made. */
|
|
95
|
+
readonly approvedTitle: string;
|
|
96
|
+
refundedBody(amountLabel: string): string;
|
|
97
|
+
/** Passed, refund still in flight. */
|
|
98
|
+
refundPendingBody(amountLabel: string): string;
|
|
99
|
+
/** The fallback for a sentence that must name an amount before one is known. */
|
|
100
|
+
readonly someAmount: string;
|
|
101
|
+
/**
|
|
102
|
+
* Refused on PAYMENT. The owner IS connected, so a sentence about the
|
|
103
|
+
* connection failing would send them to reauthorize something that works.
|
|
104
|
+
*/
|
|
105
|
+
readonly authenticatedNotActive: string;
|
|
106
|
+
/** Refused at CREATION — a provider-side switch, which is a step not an error. */
|
|
107
|
+
refusedTitle(displayName: string): string;
|
|
108
|
+
refusedBody(displayName: string): string;
|
|
109
|
+
/** The provider was never reached: it refused nothing, so blame nothing. */
|
|
110
|
+
readonly unreachableTitle: string;
|
|
111
|
+
/** The link's window elapsed unpaid. No blame, and the offer of another. */
|
|
112
|
+
readonly expiredTitle: string;
|
|
113
|
+
/** The redirect flow's own success, which settles without a refund leg. */
|
|
114
|
+
readonly settledTitle: string;
|
|
115
|
+
settledBody(amountLabel: string): string;
|
|
116
|
+
/** Already proven — the only honest thing the step can render on a reload. */
|
|
117
|
+
readonly provenTitle: string;
|
|
118
|
+
readonly provenBody: string;
|
|
119
|
+
/** The provider's raw refusal, above the verbatim block. */
|
|
120
|
+
readonly providerSaid: string;
|
|
121
|
+
/** An earlier step is unconfirmed, so no new charge is offered yet. */
|
|
122
|
+
readonly blockedTitle: string;
|
|
123
|
+
readonly blockedBody: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The CPF field the card flow asks for beside the card. */
|
|
127
|
+
export interface ActivationTaxIdCopy {
|
|
128
|
+
readonly label: string;
|
|
129
|
+
readonly hint: string;
|
|
130
|
+
readonly placeholder: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Everything step 3 says.
|
|
135
|
+
*
|
|
136
|
+
* `charge` and `redirect` are the two protocol packs the hooks already
|
|
137
|
+
* required — carried here rather than beside them so a host answers the step
|
|
138
|
+
* in ONE object, which is what stops half of it going unread.
|
|
139
|
+
*/
|
|
140
|
+
export interface ActivationStepCopy {
|
|
141
|
+
readonly intro: ActivationIntroCopy;
|
|
142
|
+
readonly actions: ActivationActionCopy;
|
|
143
|
+
readonly awaiting: ActivationAwaitingCopy;
|
|
144
|
+
readonly outcome: ActivationOutcomeCopy;
|
|
145
|
+
readonly taxId: ActivationTaxIdCopy;
|
|
146
|
+
/** The card protocol's three sentences, plus the card form's own words. */
|
|
147
|
+
readonly charge: ActivationChargeCopy;
|
|
148
|
+
/** The redirect protocol's four. */
|
|
149
|
+
readonly redirect: RedirectActivationCopy;
|
|
150
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createActivationStep, type ActivationStepConfig, type ActivationStepProps } from './activation-step';
|
|
2
|
+
export { type CardSurface } from './card-verification';
|
|
3
|
+
export {
|
|
4
|
+
type ActivationActionCopy,
|
|
5
|
+
type ActivationAwaitingCopy,
|
|
6
|
+
type ActivationIntroCopy,
|
|
7
|
+
type ActivationOutcomeCopy,
|
|
8
|
+
type ActivationStepCopy,
|
|
9
|
+
type ActivationTaxIdCopy,
|
|
10
|
+
} from './copy';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Stack, Typography } from '@mui/material';
|
|
4
|
+
import type { JSX, ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { T } from '../../components/panel-tokens';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The tinted panel every settled state is made of.
|
|
10
|
+
*
|
|
11
|
+
* Local to this step rather than a design-system Alert, and that is the whole
|
|
12
|
+
* point of the move: the origin host rendered these through its own Alert and
|
|
13
|
+
* then had to correct it twice in place — a 12px radius against the 4px cards
|
|
14
|
+
* it sat under, and a hover lift on a standing panel that moved under the
|
|
15
|
+
* cursor as you reached for the buttons inside it. Both corrections were
|
|
16
|
+
* written as `sx` overrides in a page file, which is where a design decision
|
|
17
|
+
* goes to be invisible. Here it is one shape, matching the cards it sits with,
|
|
18
|
+
* for every host.
|
|
19
|
+
*/
|
|
20
|
+
type NoticeTone = 'ok' | 'warn' | 'bad' | 'info';
|
|
21
|
+
|
|
22
|
+
const TONES = {
|
|
23
|
+
ok: { bg: T.okSoft, line: T.okLine, ink: T.okInk },
|
|
24
|
+
warn: { bg: T.warnSoft, line: T.warnLine, ink: T.warnInk },
|
|
25
|
+
bad: { bg: T.badSoft, line: T.badLine, ink: T.badInk },
|
|
26
|
+
info: { bg: T.infoSoft, line: T.infoLine, ink: T.infoInk },
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function Notice({
|
|
30
|
+
tone,
|
|
31
|
+
title,
|
|
32
|
+
description,
|
|
33
|
+
children,
|
|
34
|
+
dataTestId,
|
|
35
|
+
}: {
|
|
36
|
+
tone: NoticeTone;
|
|
37
|
+
title: string;
|
|
38
|
+
/** The body, when there is one — a state may be its title alone. */
|
|
39
|
+
description?: string;
|
|
40
|
+
/** What this outcome OFFERS, inside the panel rather than under it. */
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
dataTestId?: string;
|
|
43
|
+
}): JSX.Element {
|
|
44
|
+
const tint = TONES[tone];
|
|
45
|
+
return (
|
|
46
|
+
<Box
|
|
47
|
+
data-testid={dataTestId}
|
|
48
|
+
sx={{
|
|
49
|
+
background: tint.bg,
|
|
50
|
+
border: `1px solid ${tint.line}`,
|
|
51
|
+
borderRadius: '8px',
|
|
52
|
+
p: '12px 14px',
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Typography sx={{ fontSize: '13.5px', fontWeight: 650, color: tint.ink }}>{title}</Typography>
|
|
56
|
+
{description ? (
|
|
57
|
+
<Typography sx={{ fontSize: '12.5px', color: T.ink2, mt: '4px', lineHeight: 1.5 }}>
|
|
58
|
+
{description}
|
|
59
|
+
</Typography>
|
|
60
|
+
) : null}
|
|
61
|
+
{children ? <Box sx={{ mt: '12px' }}>{children}</Box> : null}
|
|
62
|
+
</Box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The provider's own refusal, verbatim — what support asks for on the phone.
|
|
68
|
+
*
|
|
69
|
+
* Selectable and monospaced on purpose: it is a string to be copied into a
|
|
70
|
+
* ticket, not prose. Rendered UNDER the notice rather than inside it, because
|
|
71
|
+
* it is evidence for the sentence above rather than part of it.
|
|
72
|
+
*/
|
|
73
|
+
export function ProviderMessage({ message, label }: { message: string; label: string }): JSX.Element {
|
|
74
|
+
return (
|
|
75
|
+
<Box
|
|
76
|
+
data-testid="verify-charge-provider-message"
|
|
77
|
+
sx={{
|
|
78
|
+
fontFamily: T.mono,
|
|
79
|
+
fontSize: '12px',
|
|
80
|
+
whiteSpace: 'pre-wrap',
|
|
81
|
+
wordBreak: 'break-word',
|
|
82
|
+
userSelect: 'text',
|
|
83
|
+
p: '10px 12px',
|
|
84
|
+
borderRadius: '8px',
|
|
85
|
+
background: T.bg2,
|
|
86
|
+
border: `1px solid ${T.line2}`,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<Typography sx={{ fontSize: '11px', color: T.ink3, mb: '4px' }}>{label}</Typography>
|
|
90
|
+
{message}
|
|
91
|
+
</Box>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The panel that frames a whole flow — the same card the steps above use. */
|
|
96
|
+
export function StepPanel({
|
|
97
|
+
children,
|
|
98
|
+
dataTestId,
|
|
99
|
+
}: {
|
|
100
|
+
children: ReactNode;
|
|
101
|
+
dataTestId: string;
|
|
102
|
+
}): JSX.Element {
|
|
103
|
+
return (
|
|
104
|
+
<Box
|
|
105
|
+
data-testid={dataTestId}
|
|
106
|
+
sx={{ border: `1px solid ${T.line}`, borderRadius: '11px', p: '16px', background: T.bg }}
|
|
107
|
+
>
|
|
108
|
+
<Stack spacing={2}>{children}</Stack>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button, Stack, Typography } from '@mui/material';
|
|
4
|
+
import { useCallback, type JSX } from 'react';
|
|
5
|
+
|
|
6
|
+
import { BTN_PRIMARY_SX, T } from '../../components/panel-tokens';
|
|
7
|
+
import { useRedirectActivation } from '../use-redirect-activation';
|
|
8
|
+
import type { RedirectActivationState } from '../redirect-state';
|
|
9
|
+
|
|
10
|
+
import { AwaitingPayment } from './awaiting-payment';
|
|
11
|
+
import { useActivationCopy } from './copy-context';
|
|
12
|
+
import { Notice, StepPanel } from './notice';
|
|
13
|
+
import { useVerificationAmount } from './use-verification-amount';
|
|
14
|
+
import { ExpiredState, FailedState, SetupIncompleteState, UnreachableState } from './states';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Step 3 for a provider whose buyer pays on ITS page.
|
|
18
|
+
*
|
|
19
|
+
* Same proof as the card form, different protocol: a REAL link through this
|
|
20
|
+
* store's own connection, paid by the owner on the provider's site, then
|
|
21
|
+
* confirmed by asking the provider. Telling an owner to "make a real low-value
|
|
22
|
+
* order and check" instead was the dead-end instruction this screen exists to
|
|
23
|
+
* abolish — if the check can be run for them, it must be.
|
|
24
|
+
*
|
|
25
|
+
* One button, one outcome: pressing it mints the charge AND lands them on the
|
|
26
|
+
* payment page (`useRedirectActivation` claims the tab inside the click, so no
|
|
27
|
+
* popup blocker can eat it). Generating a charge and then asking them to find a
|
|
28
|
+
* second button was the same extra step in a smaller costume.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The pay button, or the reason it is not there yet.
|
|
33
|
+
*
|
|
34
|
+
* `blocked` withholds the button ONLY. Everything else this panel can render —
|
|
35
|
+
* an outstanding charge, a return trip being confirmed, a settled result — goes
|
|
36
|
+
* on rendering regardless, because those states describe money that has already
|
|
37
|
+
* moved. Hiding them behind a setup step is how a payment stops being
|
|
38
|
+
* confirmable, which is a far worse failure than an early click.
|
|
39
|
+
*/
|
|
40
|
+
function StartCharge({
|
|
41
|
+
amountLabel,
|
|
42
|
+
blocked,
|
|
43
|
+
creating,
|
|
44
|
+
onStart,
|
|
45
|
+
}: {
|
|
46
|
+
amountLabel: string;
|
|
47
|
+
blocked: boolean;
|
|
48
|
+
creating: boolean;
|
|
49
|
+
onStart: () => void;
|
|
50
|
+
}): JSX.Element {
|
|
51
|
+
const { outcome, actions } = useActivationCopy();
|
|
52
|
+
if (blocked) {
|
|
53
|
+
return (
|
|
54
|
+
<Notice
|
|
55
|
+
tone="info"
|
|
56
|
+
title={outcome.blockedTitle}
|
|
57
|
+
description={outcome.blockedBody}
|
|
58
|
+
dataTestId="verify-charge-blocked"
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
<Button
|
|
64
|
+
sx={{ ...BTN_PRIMARY_SX, width: '100%' }}
|
|
65
|
+
disabled={creating}
|
|
66
|
+
onClick={onStart}
|
|
67
|
+
data-testid="verify-charge-start-redirect"
|
|
68
|
+
>
|
|
69
|
+
{actions.payAndActivate(amountLabel)}
|
|
70
|
+
</Button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What is about to happen, said BEFORE it happens.
|
|
76
|
+
*
|
|
77
|
+
* FUT-463 asks that whatever is taken be reversed or explained beforehand, and
|
|
78
|
+
* this is the explanation: whose money moves, and where to. Both sentences are
|
|
79
|
+
* the host's, because the first attempt at them was true and still misread —
|
|
80
|
+
* an owner asked what it meant. Nothing is lost: the charge goes through the
|
|
81
|
+
* store's OWN connection, so it lands in the account that receives its sales,
|
|
82
|
+
* and there is no refund because it never left.
|
|
83
|
+
*/
|
|
84
|
+
function Intro({ amountLabel }: { amountLabel: string }): JSX.Element {
|
|
85
|
+
const { intro } = useActivationCopy();
|
|
86
|
+
return (
|
|
87
|
+
<Stack spacing={1}>
|
|
88
|
+
<Typography sx={{ fontSize: '14px', fontWeight: 650, color: T.ink }} component="h2">
|
|
89
|
+
{intro.title}
|
|
90
|
+
</Typography>
|
|
91
|
+
<Typography sx={{ fontSize: '12.5px', color: T.ink3, lineHeight: 1.5 }}>
|
|
92
|
+
{intro.realCharge(amountLabel)}
|
|
93
|
+
</Typography>
|
|
94
|
+
<Typography sx={{ fontSize: '12.5px', color: T.ink3, lineHeight: 1.5 }}>
|
|
95
|
+
{intro.payingYourself(amountLabel)}
|
|
96
|
+
</Typography>
|
|
97
|
+
</Stack>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Everything this step can be, once it has done something.
|
|
103
|
+
*
|
|
104
|
+
* Six states, each with its own sentence and its own next action. Three of the
|
|
105
|
+
* six are ways the link never got minted, and they are three screens rather
|
|
106
|
+
* than one because they are three different instructions: fix a setting
|
|
107
|
+
* (refused), wait it out (unreachable), read the provider's own words (anything
|
|
108
|
+
* else). One panel served all three and could only ever give the first one's
|
|
109
|
+
* advice.
|
|
110
|
+
*/
|
|
111
|
+
function Outcome({
|
|
112
|
+
state,
|
|
113
|
+
displayName,
|
|
114
|
+
amountLabel,
|
|
115
|
+
lastCheckedAt,
|
|
116
|
+
onCheckNow,
|
|
117
|
+
onReset,
|
|
118
|
+
}: {
|
|
119
|
+
state: RedirectActivationState;
|
|
120
|
+
displayName: string;
|
|
121
|
+
amountLabel: string;
|
|
122
|
+
lastCheckedAt: number;
|
|
123
|
+
onCheckNow: () => Promise<void>;
|
|
124
|
+
onReset: () => void;
|
|
125
|
+
}): JSX.Element | null {
|
|
126
|
+
const { outcome } = useActivationCopy();
|
|
127
|
+
if (state.kind === 'passed') {
|
|
128
|
+
return (
|
|
129
|
+
<Notice
|
|
130
|
+
tone="ok"
|
|
131
|
+
title={outcome.settledTitle}
|
|
132
|
+
description={outcome.settledBody(amountLabel)}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (state.kind === 'expired') {
|
|
137
|
+
return <ExpiredState reason={state.reason} onRegenerate={onReset} />;
|
|
138
|
+
}
|
|
139
|
+
if (state.kind === 'awaiting') {
|
|
140
|
+
return (
|
|
141
|
+
<AwaitingPayment
|
|
142
|
+
checkoutUrl={state.checkoutUrl}
|
|
143
|
+
amountLabel={amountLabel}
|
|
144
|
+
declined={state.declined}
|
|
145
|
+
lastCheckedAt={lastCheckedAt}
|
|
146
|
+
onCheckNow={onCheckNow}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (state.kind !== 'failed') return null;
|
|
151
|
+
if (!state.atCreation) {
|
|
152
|
+
return (
|
|
153
|
+
<FailedState reason={state.reason} providerMessage={state.providerMessage} onRetry={onReset} />
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (state.transport) {
|
|
157
|
+
return (
|
|
158
|
+
<UnreachableState
|
|
159
|
+
reason={state.reason}
|
|
160
|
+
providerMessage={state.providerMessage}
|
|
161
|
+
onRetry={onReset}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<SetupIncompleteState
|
|
167
|
+
displayName={displayName}
|
|
168
|
+
reason={state.reason}
|
|
169
|
+
providerMessage={state.providerMessage}
|
|
170
|
+
onDismiss={onReset}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function RedirectVerification({
|
|
176
|
+
verifyChargeUrl,
|
|
177
|
+
displayName,
|
|
178
|
+
blocked,
|
|
179
|
+
hidden,
|
|
180
|
+
storageKey,
|
|
181
|
+
formatAmount,
|
|
182
|
+
onVerified,
|
|
183
|
+
onSetupIncomplete,
|
|
184
|
+
}: {
|
|
185
|
+
verifyChargeUrl: string;
|
|
186
|
+
/** The provider's human name, for the sentences that must say who refused. */
|
|
187
|
+
displayName: string;
|
|
188
|
+
blocked: boolean;
|
|
189
|
+
/** The walkthrough is on an earlier step — see `renderVerification`'s `hidden`. */
|
|
190
|
+
hidden: boolean;
|
|
191
|
+
storageKey?: string;
|
|
192
|
+
formatAmount: (cents: number) => string;
|
|
193
|
+
onVerified: () => void;
|
|
194
|
+
onSetupIncomplete: () => void;
|
|
195
|
+
}): JSX.Element | null {
|
|
196
|
+
const copy = useActivationCopy();
|
|
197
|
+
// Still memoized: the hook reads its callbacks through a ref inside the
|
|
198
|
+
// effects, but `onCreateFailed` remains a dependency of `start`, so a fresh
|
|
199
|
+
// identity every render would rebuild it on every unrelated keystroke.
|
|
200
|
+
const onCreateFailed = useCallback(() => onSetupIncomplete(), [onSetupIncomplete]);
|
|
201
|
+
const { state, lastCheckedAt, start, checkNow, reset } = useRedirectActivation({
|
|
202
|
+
verifyChargeUrl,
|
|
203
|
+
onVerified,
|
|
204
|
+
onCreateFailed,
|
|
205
|
+
copy: copy.redirect,
|
|
206
|
+
storageKey,
|
|
207
|
+
});
|
|
208
|
+
// Named from the server, because it is not always a cent — see
|
|
209
|
+
// `useVerificationAmount`. A button promising one figure while charging
|
|
210
|
+
// another is the kind of lie this whole flow exists to remove, and an
|
|
211
|
+
// unpriced charge says so rather than guessing.
|
|
212
|
+
const cents = useVerificationAmount(verifyChargeUrl);
|
|
213
|
+
const amountLabel = cents === null ? copy.outcome.someAmount : formatAmount(cents);
|
|
214
|
+
|
|
215
|
+
// Off the current step and with nothing outstanding: the walkthrough is
|
|
216
|
+
// showing something earlier and this panel has nothing to add.
|
|
217
|
+
//
|
|
218
|
+
// NOT unmounted whenever `hidden` is true. The provider refusing to mint a
|
|
219
|
+
// link is exactly the evidence that withdraws the owner's step-2
|
|
220
|
+
// confirmation — so the guide goes back a step in the same render that
|
|
221
|
+
// produced the explanation, and unmounting took the explanation with it. The
|
|
222
|
+
// owner landed back on a step they thought was finished with nothing on
|
|
223
|
+
// screen saying why.
|
|
224
|
+
if (hidden && (state.kind === 'idle' || state.kind === 'creating')) return null;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<StepPanel dataTestId="verify-charge-redirect">
|
|
228
|
+
{hidden ? null : <Intro amountLabel={amountLabel} />}
|
|
229
|
+
|
|
230
|
+
<Outcome
|
|
231
|
+
state={state}
|
|
232
|
+
displayName={displayName}
|
|
233
|
+
amountLabel={amountLabel}
|
|
234
|
+
lastCheckedAt={lastCheckedAt}
|
|
235
|
+
onCheckNow={checkNow}
|
|
236
|
+
onReset={reset}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
{state.kind === 'idle' || state.kind === 'creating' ? (
|
|
240
|
+
<StartCharge
|
|
241
|
+
amountLabel={amountLabel}
|
|
242
|
+
blocked={blocked}
|
|
243
|
+
creating={state.kind === 'creating'}
|
|
244
|
+
onStart={() => void start()}
|
|
245
|
+
/>
|
|
246
|
+
) : null}
|
|
247
|
+
</StepPanel>
|
|
248
|
+
);
|
|
249
|
+
}
|