@12-apps/payments-frontend 1.0.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.
Files changed (34) hide show
  1. package/eslint.config.js +34 -0
  2. package/package.json +63 -0
  3. package/src/__tests__/checkout-confirmation.test.tsx +177 -0
  4. package/src/__tests__/connection-state.test.tsx +84 -0
  5. package/src/__tests__/context.test.tsx +88 -0
  6. package/src/__tests__/controlled-provider.test.tsx +116 -0
  7. package/src/__tests__/credential-confirm.test.tsx +193 -0
  8. package/src/__tests__/initial-provider.test.tsx +81 -0
  9. package/src/__tests__/provider-priority-list.test.tsx +159 -0
  10. package/src/__tests__/provider-status-bar.test.tsx +152 -0
  11. package/src/__tests__/verification-slot.test.tsx +125 -0
  12. package/src/client.ts +200 -0
  13. package/src/components/CheckoutFlow.tsx +169 -0
  14. package/src/components/CheckoutPayment.tsx +379 -0
  15. package/src/components/ConfirmCredentialSave.tsx +106 -0
  16. package/src/components/CredentialFields.tsx +158 -0
  17. package/src/components/CredentialFormAlerts.tsx +144 -0
  18. package/src/components/EnvironmentTabs.tsx +109 -0
  19. package/src/components/PaymentProviderSettings.tsx +267 -0
  20. package/src/components/ProviderConnection.tsx +251 -0
  21. package/src/components/ProviderCredentialForm.tsx +387 -0
  22. package/src/components/ProviderList.tsx +126 -0
  23. package/src/components/ProviderPanel.tsx +300 -0
  24. package/src/components/ProviderPriorityList.tsx +293 -0
  25. package/src/components/ProviderSetupGuide.tsx +263 -0
  26. package/src/components/ProviderStatusBar.tsx +192 -0
  27. package/src/components/SetupGuideSection.tsx +190 -0
  28. package/src/components/checkout-ack.ts +106 -0
  29. package/src/components/connection-state.ts +66 -0
  30. package/src/components/credential-rules.ts +120 -0
  31. package/src/components/rich-text.tsx +27 -0
  32. package/src/components/settings-state.ts +211 -0
  33. package/src/context.tsx +144 -0
  34. package/src/index.ts +69 -0
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import { Alert, Button, CircularProgress, Stack, Typography } from '@mui/material';
4
+
5
+ import type { VerifiedProviderConfig } from '@12-apps/payments-backend';
6
+
7
+ import { ENVIRONMENT_LABELS } from './EnvironmentTabs';
8
+
9
+ /**
10
+ * The credential step's alerts and its one button.
11
+ *
12
+ * Split from `ProviderCredentialForm` because that file is about the FORM's
13
+ * state machine — what is typed, what is stored, what the probe last said —
14
+ * while these three are about the sentences the owner reads when it goes
15
+ * wrong, and each of those sentences was learned from a screen that got it
16
+ * wrong in a way that cost somebody money.
17
+ */
18
+
19
+ // Named for what it is here, not `ProbeResult` — the payments package already
20
+ // exports a different `ProbeResult` (failover classification).
21
+ export type VerifyProbe = VerifiedProviderConfig['probe'];
22
+
23
+ /**
24
+ * What the probe found — only when it found something WRONG.
25
+ *
26
+ * A passing probe already has a place to be said: the `CONEXÃO OK` chip in the
27
+ * header, which persists. Repeating it as a green banner mid-flow added a
28
+ * fourth block to a screen whose whole point is showing one step at a time,
29
+ * and said nothing the chip had not. A failure is different: the chip can only
30
+ * report that something is wrong, not which environment or what to do, so that
31
+ * one still gets its sentence.
32
+ */
33
+ export function ProbeAlert({
34
+ probe,
35
+ busy,
36
+ onRetry,
37
+ }: {
38
+ probe: VerifyProbe;
39
+ busy: boolean;
40
+ onRetry: () => void;
41
+ }) {
42
+ if (probe.ok) return null;
43
+ const where = ENVIRONMENT_LABELS[probe.environment];
44
+ // The ADAPTER's sentence when it has one. Only it knows the provider's name,
45
+ // what that provider's own app calls this field and which screen states it —
46
+ // "confira as credenciais deste ambiente" is advice you can follow all
47
+ // afternoon without ever finding the value you were asked to check.
48
+ const message =
49
+ probe.message ??
50
+ `Não foi possível conectar em ${where}. Confira as credenciais deste ambiente.`;
51
+
52
+ // An outage is not a rejection. The credential is already SAVED and may well
53
+ // be perfect; nothing was learned about it. So it is amber rather than red,
54
+ // and it carries the only control that helps — ask again. Without one the
55
+ // owner's sole route back was to re-save a value that was never wrong, and
56
+ // saving is what resets the connection.
57
+ const unreachable = probe.fault === 'UNREACHABLE';
58
+ return (
59
+ <Alert
60
+ severity={unreachable ? 'warning' : 'error'}
61
+ data-testid="payments-probe-result"
62
+ action={
63
+ unreachable ? (
64
+ <Button
65
+ size="small"
66
+ disabled={busy}
67
+ onClick={onRetry}
68
+ data-testid="payments-verify-retry"
69
+ sx={{ textTransform: 'none' }}
70
+ >
71
+ Testar conexão
72
+ </Button>
73
+ ) : undefined
74
+ }
75
+ >
76
+ {message}
77
+ </Alert>
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Changing the tag on a store that has ALREADY proved it can receive.
83
+ *
84
+ * The dialog two steps later asks "is this the right account"; this answers a
85
+ * different question the owner has not thought to ask — what happens to the
86
+ * store while they do it. A save invalidates the proof and drops the provider
87
+ * out of the failover chain (`applySaveCredentials`), so the shop stops taking
88
+ * money through it until a new activation charge lands. That is entirely
89
+ * correct and entirely invisible, and an owner who came to fix a typo deserves
90
+ * to learn it before typing rather than from a quiet storefront.
91
+ */
92
+ export function ReverifyWarning() {
93
+ return (
94
+ <Alert severity="warning" data-testid="payments-reverify-warning">
95
+ Esta loja já está verificada. Trocar a InfiniteTag <strong>exige uma nova verificação</strong>{' '}
96
+ e a loja <strong>para de receber</strong> por este provedor até que ela termine.
97
+ </Alert>
98
+ );
99
+ }
100
+
101
+ /** Salvar + Testar conexão, each dead when it would do nothing (or harm). */
102
+ /**
103
+ * One button. Saving IS testing.
104
+ *
105
+ * "Testar conexão" was a second button the owner had to know to press, guarding
106
+ * a step they could not finish without it — and its enablement rule was the
107
+ * inverse of Salvar's (dead while the form was dirty, live only once it was
108
+ * clean), so the two took turns being grey for reasons no label explained.
109
+ * Nothing is learned by separating them: the probe reads what was just stored,
110
+ * so the only useful moment to run it is immediately after a save.
111
+ */
112
+ export function FormActions({
113
+ busy,
114
+ label,
115
+ disabled,
116
+ onSave,
117
+ }: {
118
+ busy: string | null;
119
+ label: string;
120
+ disabled: boolean;
121
+ onSave: () => void;
122
+ }) {
123
+ return (
124
+ <Stack spacing={1} alignItems="flex-start">
125
+ <Button
126
+ variant="contained"
127
+ data-testid="payments-save"
128
+ disabled={busy !== null || disabled}
129
+ sx={{ textTransform: 'none' }}
130
+ onClick={onSave}
131
+ >
132
+ {busy === 'save' ? <CircularProgress size={18} /> : label}
133
+ </Button>
134
+ {busy === 'verify' ? (
135
+ <Stack direction="row" spacing={1} alignItems="center" data-testid="payments-verifying">
136
+ <CircularProgress size={14} />
137
+ <Typography variant="body2" color="text.secondary">
138
+ Testando a conexão…
139
+ </Typography>
140
+ </Stack>
141
+ ) : null}
142
+ </Stack>
143
+ );
144
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { Alert, Tab, Tabs } from '@mui/material';
4
+
5
+ import type { PaymentEnvironment } from '@12-apps/payments-backend';
6
+
7
+ /** One environment's human name — the tabs and the probe result share it. */
8
+ export const ENVIRONMENT_LABELS: Record<PaymentEnvironment, string> = {
9
+ SANDBOX: 'Sandbox',
10
+ PRODUCTION: 'Produção',
11
+ };
12
+
13
+ /**
14
+ * Sentence case, because these are place names rather than shouted commands —
15
+ * MUI upper-cases tab labels by default, which turns "Produção" into signage.
16
+ */
17
+ const TAB_SX = { textTransform: 'none' } as const;
18
+
19
+ /**
20
+ * The two environments as tabs, not a select: there are exactly two, and which
21
+ * one you are editing changes what every field below it means. A closed
22
+ * dropdown hides that, and credentials are stored PER environment — so pasting
23
+ * a production token while "Sandbox" sits collapsed above is a mistake the
24
+ * control itself should not allow.
25
+ */
26
+ export function EnvironmentSelector({
27
+ environment,
28
+ onChange,
29
+ }: {
30
+ environment: PaymentEnvironment;
31
+ onChange: (next: PaymentEnvironment) => void;
32
+ }) {
33
+ return (
34
+ <Tabs
35
+ value={environment}
36
+ onChange={(_, next: PaymentEnvironment) => onChange(next)}
37
+ aria-label="Ambiente"
38
+ data-testid="payments-environment-tabs"
39
+ >
40
+ <Tab
41
+ label="Sandbox"
42
+ value="SANDBOX"
43
+ data-testid="payments-environment-SANDBOX"
44
+ sx={TAB_SX}
45
+ />
46
+ <Tab
47
+ label="Produção"
48
+ value="PRODUCTION"
49
+ data-testid="payments-environment-PRODUCTION"
50
+ sx={TAB_SX}
51
+ />
52
+ </Tabs>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * A banner that makes Produção LOOK different from Sandbox.
58
+ *
59
+ * Two tabs of identical fields, differing by one word in a tab label, is the
60
+ * whole of what distinguished "type here and nothing happens" from "type here
61
+ * and this is what charges your customers". The tab you are on is the least
62
+ * noticeable thing on the screen, and the consequences of being on the wrong
63
+ * one are entirely invisible until money moves.
64
+ *
65
+ * It states only what this package can actually back. NOT "nothing is real in
66
+ * Sandbox": whether a sandbox call is faked depends on the deployment, and
67
+ * InfinitePay has no sandbox at all — a reassurance that is sometimes false is
68
+ * worse than none. What is always true is that the two credential sets are
69
+ * separate, and which one serves the store's checkout is `config.environment`
70
+ * — so when you are looking at the other one, the banner says so.
71
+ */
72
+ export function EnvironmentNotice({
73
+ environment,
74
+ active,
75
+ }: {
76
+ environment: PaymentEnvironment;
77
+ /** The environment this store's real checkout uses, when it has one. */
78
+ active: PaymentEnvironment | null;
79
+ }) {
80
+ const elsewhere = active !== null && active !== environment ? ENVIRONMENT_LABELS[active] : null;
81
+ const production = environment === 'PRODUCTION';
82
+ return (
83
+ <Alert
84
+ severity={production ? 'warning' : 'info'}
85
+ variant="standard"
86
+ square
87
+ data-testid={`payments-environment-notice-${environment}`}
88
+ sx={{ borderRadius: 0, py: 0.5, px: 3 }}
89
+ >
90
+ <strong>
91
+ {production ? 'Produção — dinheiro real.' : 'Sandbox — ambiente de teste.'}
92
+ </strong>{' '}
93
+ {production
94
+ ? 'Tudo o que você fizer aqui vale para as vendas da loja.'
95
+ : 'Nenhum valor sai ou entra de verdade.'}
96
+ {elsewhere ? ` Hoje a loja está usando ${elsewhere}.` : ''}
97
+ </Alert>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The banner is deliberately NOT bundled with the tabs any more.
103
+ *
104
+ * It has to run edge to edge across the provider card — a full-bleed strip is
105
+ * what makes it read as a property of everything below it, rather than as one
106
+ * more paragraph indented inside the content. That is only possible if the card
107
+ * has a padded header and an unpadded band between it and the body, so the two
108
+ * halves are placed separately by `ActivePanel`.
109
+ */
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, CircularProgress } from '@mui/material';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import type { MaskedProviderConfig } from '@12-apps/payments-backend';
7
+
8
+ import type { PaymentsSettingsClient } from '../client';
9
+ import { canAttemptCharge } from './connection-state';
10
+ import { ProviderList } from './ProviderList';
11
+ import { ActivePanel, type ActivePanelProps, type PrepareConnect } from './ProviderPanel';
12
+ import {
13
+ guideAwaitsConfirmation,
14
+ progressKeyOf,
15
+ useOpenProvider,
16
+ useSelectedProvider,
17
+ useSettingsState,
18
+ useSetupConfirmation,
19
+ useSetupGuide,
20
+ } from './settings-state';
21
+ import { openSection, SetupGuideSection } from './SetupGuideSection';
22
+
23
+ /**
24
+ * Plug-and-play settings page for payment providers — the reusable
25
+ * equivalent of the app's per-provider config screen:
26
+ *
27
+ * - the FAILOVER CHAIN: the enabled providers in the order checkout will
28
+ * try them, reorderable by drag or by keyboard
29
+ * - provider selector (every adapter registered in the backend)
30
+ * - per provider, EITHER a connect button (`authMode: 'oauth'`) or a
31
+ * schema-driven credential form — and for OAuth providers, both
32
+ * - SANDBOX/PRODUCTION environment switch (both credential sets kept)
33
+ * - save / verify ("test connection") / enable (join or leave the chain)
34
+ * - the provider's own onboarding walkthrough
35
+ *
36
+ * The host provides only the authenticated `client` and (optionally) the
37
+ * connect-state minting for OAuth providers.
38
+ */
39
+ export interface PaymentProviderSettingsProps {
40
+ client: PaymentsSettingsClient;
41
+ /** Called after any state-changing action, e.g. to refresh host UI. */
42
+ onChanged?: (config: MaskedProviderConfig) => void;
43
+ /**
44
+ * Required to render `authMode: 'oauth'` providers: the host mints and
45
+ * persists the CSRF state against the admin session and returns it with
46
+ * the callback URL. Omit it and OAuth providers fall back to their
47
+ * credential form instead of showing a connect button that cannot work.
48
+ */
49
+ prepareConnect?: PrepareConnect;
50
+ /**
51
+ * Provider to open on mount instead of the list.
52
+ *
53
+ * The OAuth callback comes back to this page having just connected one — and
54
+ * landing on the provider LIST at that moment hides the very thing that
55
+ * changed, along with the switch that still has to be flipped. The host knows
56
+ * which provider it was (its own callback said so); the package does not read
57
+ * the URL.
58
+ */
59
+ initialProvider?: string | null;
60
+ /**
61
+ * Which provider is open, when the HOST owns that state (e.g. it lives in
62
+ * the URL, so each provider is its own linkable page).
63
+ *
64
+ * Passing it — `null` included — switches this component to controlled mode:
65
+ * it stops keeping its own selection and `initialProvider` no longer applies,
66
+ * because the host's value is now the single source of truth. Leave it
67
+ * `undefined` and nothing changes: selection stays internal, as every
68
+ * existing caller expects.
69
+ */
70
+ selectedProvider?: string | null;
71
+ /**
72
+ * Fired when the owner picks a provider (or leaves one, with `null`).
73
+ *
74
+ * Independent of `selectedProvider`: an uncontrolled host can use this purely
75
+ * to observe, while a controlled host uses it to write the new selection
76
+ * wherever it keeps it.
77
+ */
78
+ onProviderChange?: (provider: string | null) => void;
79
+ /**
80
+ * The activation step, rendered under the connection card.
81
+ *
82
+ * "Connected" and "can charge" are different facts, and only the HOST can
83
+ * prove the second one: it owns the endpoint that puts a real R$0,01 through
84
+ * the store's own account and enables the provider when it lands (FUT-463).
85
+ * The package decides only WHERE that step appears, never how it works — the
86
+ * same split as `prepareConnect`.
87
+ *
88
+ * `onVerified` refreshes this screen, so a passing charge immediately shows
89
+ * the provider as active.
90
+ */
91
+ renderVerification?: (context: {
92
+ provider: string;
93
+ /**
94
+ * The provider's human name ("InfinitePay").
95
+ *
96
+ * The activation step tells the owner who refused what, and "o provedor
97
+ * recusou criar a cobrança" is the same sentence with the one word that
98
+ * makes it actionable removed — an owner reading it on a screen that lists
99
+ * three providers has to work out which one is being talked about.
100
+ */
101
+ displayName: string;
102
+ /** A stored connection exists — there is something to charge through. */
103
+ connected: boolean;
104
+ /**
105
+ * A real charge through this connection has already succeeded
106
+ * (`chargeVerifiedAt` is stamped). The step renders a confirmation instead
107
+ * of demanding another payment: an owner who has ALREADY paid the R$0,01
108
+ * and reloads must be told it worked, not shown the pay button again — the
109
+ * button that being shown again is what made one owner pay four times.
110
+ */
111
+ proven: boolean;
112
+ /**
113
+ * An earlier step is still outstanding, so a NEW charge must not be
114
+ * offered yet — the owner has not confirmed the provider-side switch
115
+ * without which no link can be minted at all.
116
+ *
117
+ * It withholds the pay BUTTON and nothing else. The step itself must go on
118
+ * rendering whatever its state: it is what resumes an outstanding charge on
119
+ * mount and what reads the `transaction_nsu` a returning payer arrives
120
+ * with, so not rendering it is how a payment that HAS been made stops being
121
+ * confirmable. A blocked screen may cost a click; an unmounted one costs
122
+ * the money.
123
+ */
124
+ blocked: boolean;
125
+ /**
126
+ * The walkthrough has an EARLIER step open, so this one is not the current
127
+ * step. It must still render whatever it has already settled.
128
+ *
129
+ * The same lesson as `blocked`, one turn further. Unmounting was how the
130
+ * one sentence explaining why step 2 had just reopened disappeared in the
131
+ * same frame that reopened it: the provider refuses to mint a link, the
132
+ * step-2 confirmation is withdrawn on that evidence, the guide goes back a
133
+ * step — and the panel carrying "the provider refused, here is why" is
134
+ * taken off screen by its own report. The owner is returned to a step they
135
+ * believed they had done, with nothing saying so.
136
+ */
137
+ hidden: boolean;
138
+ onVerified: () => void;
139
+ /**
140
+ * The provider refused to create the charge — the strongest evidence
141
+ * available that the step the owner ticked off is not in fact done. Undoes
142
+ * the confirmation and puts them back on it.
143
+ */
144
+ onSetupIncomplete: () => void;
145
+ }) => ReactNode;
146
+ }
147
+
148
+ /** Everything the activation step needs that only this screen knows. */
149
+ interface VerificationInputs {
150
+ render: PaymentProviderSettingsProps['renderVerification'];
151
+ provider: string;
152
+ displayName: string;
153
+ config: MaskedProviderConfig | null;
154
+ /** An earlier step is unfinished: withhold the pay button, render the rest. */
155
+ blocked: boolean;
156
+ /** The walkthrough is on an earlier step; this one is not on screen at all. */
157
+ hidden: boolean;
158
+ onVerified: () => void;
159
+ onSetupIncomplete: () => void;
160
+ }
161
+
162
+ /** The activation step's context, built from what this screen already knows. */
163
+ function verificationFor(io: VerificationInputs): ReactNode {
164
+ // `enabled` is deliberately NOT passed: the step used it to show a standing
165
+ // "provider is active" banner, which kept reassuring a store whose every
166
+ // charge was being refused. `proven` is a different kind of fact — it is
167
+ // stamped only by a real charge landing, which is exactly what this step
168
+ // exists to observe.
169
+ return io.render?.({
170
+ provider: io.provider,
171
+ displayName: io.displayName,
172
+ connected: canAttemptCharge(io.config),
173
+ proven: Boolean(io.config?.chargeVerifiedAt),
174
+ blocked: io.blocked,
175
+ // Never `hidden` once the charge has landed: then this step IS the
176
+ // confirmation, and the guide has nothing left to show anyway.
177
+ hidden: io.hidden && !io.config?.chargeVerifiedAt,
178
+ onVerified: io.onVerified,
179
+ onSetupIncomplete: io.onSetupIncomplete,
180
+ });
181
+ }
182
+
183
+ interface ProviderScreenProps extends ActivePanelProps {
184
+ onBack: () => void;
185
+ }
186
+
187
+ /** One provider's configuration, with the way back to the list. */
188
+ function ProviderScreen({ onBack, ...panel }: ProviderScreenProps) {
189
+ return (
190
+ <Box data-testid="payments-provider-settings">
191
+ <Button
192
+ size="small"
193
+ onClick={onBack}
194
+ data-testid="payments-provider-back"
195
+ sx={{ mb: 2, textTransform: 'none' }}
196
+ >
197
+ ← Voltar aos provedores
198
+ </Button>
199
+ <ActivePanel {...panel} />
200
+ </Box>
201
+ );
202
+ }
203
+
204
+ export function PaymentProviderSettings({
205
+ client,
206
+ onChanged,
207
+ prepareConnect,
208
+ initialProvider = null,
209
+ selectedProvider,
210
+ onProviderChange,
211
+ renderVerification,
212
+ }: PaymentProviderSettingsProps) {
213
+ const { view, error, reload } = useSettingsState(client);
214
+ const { selected, setSelected } = useSelectedProvider(
215
+ initialProvider,
216
+ selectedProvider,
217
+ onProviderChange,
218
+ );
219
+ // Resolved above the early returns, because the guide hook depends on both.
220
+ const { active, activeConfig } = useOpenProvider(view, selected);
221
+ const { guide, loaded } = useSetupGuide(client, active?.name ?? null, progressKeyOf(activeConfig));
222
+ const ack = useSetupConfirmation(client, active, activeConfig);
223
+
224
+ if (error) return <Alert severity="error">{error}</Alert>;
225
+ if (!view) return <CircularProgress data-testid="payments-settings-loading" />;
226
+
227
+ if (!active) {
228
+ return <ProviderList view={view} client={client} reload={reload} onSelect={setSelected} />;
229
+ }
230
+
231
+ return (
232
+ <ProviderScreen
233
+ descriptor={active}
234
+ config={activeConfig}
235
+ client={client}
236
+ onChanged={onChanged}
237
+ reload={() => void reload()}
238
+ prepareConnect={prepareConnect}
239
+ onBack={() => setSelected(null)}
240
+ // A new credential may name a DIFFERENT InfinitePay account, and the
241
+ // owner's "Checkout Integrado is on" was said about the old one. The
242
+ // server already drops its own verdict and its proof on any credential
243
+ // change; this drops the half it cannot see.
244
+ onCredentialsReplaced={ack.withdraw}
245
+ verification={verificationFor({
246
+ render: renderVerification,
247
+ provider: active.name,
248
+ displayName: active.displayName,
249
+ config: activeConfig,
250
+ blocked: guideAwaitsConfirmation(guide, ack.confirmed, loaded),
251
+ // The walkthrough still has a step to show ⇒ this is not that step.
252
+ hidden: !loaded || openSection(guide, ack.confirmed, false) !== null,
253
+ onVerified: () => void reload(),
254
+ onSetupIncomplete: ack.withdraw,
255
+ })}
256
+ guide={(slots) => (
257
+ <SetupGuideSection
258
+ guide={guide}
259
+ confirmed={ack.confirmed}
260
+ onConfirm={ack.confirm}
261
+ onReopen={ack.withdraw}
262
+ {...slots}
263
+ />
264
+ )}
265
+ />
266
+ );
267
+ }