@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.
@@ -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
+ }