@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,251 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Alert,
5
+ Button,
6
+ Chip,
7
+ CircularProgress,
8
+ Dialog,
9
+ DialogActions,
10
+ DialogContent,
11
+ DialogContentText,
12
+ DialogTitle,
13
+ Stack,
14
+ Typography,
15
+ } from '@mui/material';
16
+ import { useState } from 'react';
17
+
18
+ import type {
19
+ MaskedProviderConfig,
20
+ PaymentEnvironment,
21
+ ProviderDescriptor,
22
+ } from '@12-apps/payments-backend';
23
+
24
+ import type { PaymentsSettingsClient } from '../client';
25
+ import { isConnected } from './connection-state';
26
+
27
+ /**
28
+ * The `authMode: 'oauth'` half of the settings page: a provider whose
29
+ * connection is a BUTTON, not a form. Replaces the credential fields with a
30
+ * connection card — connect / reconnect / disconnect plus the connection's
31
+ * live state.
32
+ *
33
+ * The `state` parameter is minted and stored server-side by the host and
34
+ * echoed back on the callback; this component only relays it, so a forged
35
+ * connect attempt cannot originate here.
36
+ */
37
+ export interface ProviderConnectionProps {
38
+ descriptor: ProviderDescriptor;
39
+ config: MaskedProviderConfig | null;
40
+ client: PaymentsSettingsClient;
41
+ /**
42
+ * Host endpoint that mints + persists the CSRF state against the admin
43
+ * session and returns it with the callback URL to come back to.
44
+ */
45
+ prepareConnect: (
46
+ provider: string,
47
+ environment: PaymentEnvironment,
48
+ ) => Promise<{ state: string; redirectUri: string; environment?: PaymentEnvironment }>;
49
+ onChanged: () => void;
50
+ }
51
+
52
+ function expiryNote(expiresAt: string | null): string | null {
53
+ if (!expiresAt) return null;
54
+ const when = new Date(expiresAt);
55
+ return `Autorização válida até ${when.toLocaleString('pt-BR')}`;
56
+ }
57
+
58
+ function connectLabel(displayName: string, connected: boolean, busy: string | null) {
59
+ if (busy === 'connect') return <CircularProgress size={18} />;
60
+ return connected ? 'Reconectar' : `Conectar com ${displayName}`;
61
+ }
62
+
63
+ /** Header + explanatory copy + any warning banner for the connection. */
64
+ function ConnectionSummary(props: {
65
+ displayName: string;
66
+ status: string;
67
+ connected: boolean;
68
+ expiresAt: string | null;
69
+ environment: PaymentEnvironment;
70
+ }) {
71
+ return (
72
+ <>
73
+ {/*
74
+ No name and no status chip here: the provider header above this card
75
+ already carries both, and printing them twice produced two "PagBank"
76
+ headings stacked on top of each other with different-looking states.
77
+ Only the environment — which the header cannot know — is shown, and only
78
+ once connected, since the provider sealed it into the grant.
79
+ */}
80
+ {props.connected ? (
81
+ <Stack direction="row" spacing={1} alignItems="center">
82
+ <Chip
83
+ size="small"
84
+ variant="outlined"
85
+ data-testid="payments-connected-environment"
86
+ label={props.environment === 'PRODUCTION' ? 'Produção' : 'Sandbox (testes)'}
87
+ color={props.environment === 'PRODUCTION' ? 'default' : 'warning'}
88
+ />
89
+ </Stack>
90
+ ) : null}
91
+ <Typography variant="body2" color="text.secondary">
92
+ {props.connected
93
+ ? 'Sua conta está conectada. O Future Pay cria as cobranças em seu nome — nenhuma chave precisa ser copiada.'
94
+ : `Conecte sua conta ${props.displayName} autorizando o acesso no site do provedor. Nenhuma chave precisa ser copiada.`}
95
+ </Typography>
96
+ {props.status === 'RECONNECT_REQUIRED' ? (
97
+ <Alert severity="warning">
98
+ A autorização expirou ou foi revogada. Reconecte para voltar a receber pagamentos.
99
+ </Alert>
100
+ ) : null}
101
+ {props.expiresAt ? (
102
+ <Typography variant="caption" color="text.secondary">
103
+ {expiryNote(props.expiresAt)}
104
+ </Typography>
105
+ ) : null}
106
+ </>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Confirmation for Desconectar, which is destructive and irreversible from
112
+ * here: it revokes the grant at the provider, so the store stops being able to
113
+ * charge immediately and getting back requires the owner to authorize again on
114
+ * the provider's site. It also sat one careless click from "Reconectar".
115
+ */
116
+ function DisconnectDialog(props: {
117
+ open: boolean;
118
+ displayName: string;
119
+ busy: boolean;
120
+ onCancel: () => void;
121
+ onConfirm: () => void;
122
+ }) {
123
+ return (
124
+ <Dialog open={props.open} onClose={props.onCancel} data-testid="payments-disconnect-confirm">
125
+ <DialogTitle>Desconectar {props.displayName}?</DialogTitle>
126
+ <DialogContent>
127
+ <DialogContentText>
128
+ A autorização será revogada no {props.displayName} e sua loja deixa de conseguir cobrar
129
+ imediatamente. Para voltar a receber, será necessário conectar a conta novamente
130
+ autorizando o acesso no site do provedor.
131
+ </DialogContentText>
132
+ </DialogContent>
133
+ <DialogActions>
134
+ <Button onClick={props.onCancel} disabled={props.busy}>
135
+ Cancelar
136
+ </Button>
137
+ <Button
138
+ color="error"
139
+ variant="contained"
140
+ onClick={props.onConfirm}
141
+ disabled={props.busy}
142
+ data-testid="payments-disconnect-confirm-action"
143
+ >
144
+ {props.busy ? <CircularProgress size={18} /> : 'Desconectar'}
145
+ </Button>
146
+ </DialogActions>
147
+ </Dialog>
148
+ );
149
+ }
150
+
151
+ function ConnectionActions(props: {
152
+ displayName: string;
153
+ connected: boolean;
154
+ busy: string | null;
155
+ onConnect: () => void;
156
+ onDisconnect: () => void;
157
+ }) {
158
+ const { displayName, connected, busy, onConnect, onDisconnect } = props;
159
+ return (
160
+ <Stack direction="row" spacing={1}>
161
+ <Button variant="contained" disabled={busy !== null} onClick={onConnect}>
162
+ {connectLabel(displayName, connected, busy)}
163
+ </Button>
164
+ {connected ? (
165
+ <Button variant="outlined" color="error" disabled={busy !== null} onClick={onDisconnect}>
166
+ {busy === 'disconnect' ? <CircularProgress size={18} /> : 'Desconectar'}
167
+ </Button>
168
+ ) : null}
169
+ </Stack>
170
+ );
171
+ }
172
+
173
+ /** One in-flight action at a time, with its failure surfaced to the owner. */
174
+ function useConnectionAction(onChanged: () => void) {
175
+ const [busy, setBusy] = useState<string | null>(null);
176
+ const [error, setError] = useState<string | null>(null);
177
+
178
+ const run = async (kind: string, action: () => Promise<void>) => {
179
+ setBusy(kind);
180
+ setError(null);
181
+ try {
182
+ await action();
183
+ onChanged();
184
+ } catch (err) {
185
+ setError(err instanceof Error ? err.message : String(err));
186
+ } finally {
187
+ setBusy(null);
188
+ }
189
+ };
190
+
191
+ return { busy, error, run };
192
+ }
193
+
194
+ export function ProviderConnection(props: ProviderConnectionProps) {
195
+ const { descriptor, config, client, prepareConnect, onChanged } = props;
196
+ const { busy, error, run } = useConnectionAction(onChanged);
197
+ const [confirmingDisconnect, setConfirmingDisconnect] = useState(false);
198
+ // Which account the owner is connecting. Follows whatever this provider is
199
+ // already configured for, defaulting to SANDBOX so a live grant is never the
200
+ // accident. Changing it is an ADVANCED action and lives with the manual
201
+ // credentials, not on the one-button connect card.
202
+ const environment: PaymentEnvironment = config?.environment ?? 'SANDBOX';
203
+ const status = config?.status ?? 'UNVERIFIED';
204
+ const connected = isConnected(config);
205
+
206
+ const connect = () =>
207
+ void run('connect', async () => {
208
+ // The host re-reads and seals the environment server-side; sending it
209
+ // here is what stops a SANDBOX choice from authorizing a LIVE account.
210
+ const ctx = await prepareConnect(descriptor.name, environment);
211
+ const { url } = await client.beginOAuth(descriptor.name, { environment, ...ctx });
212
+ // Full-page navigation: the provider's consent screen must be a
213
+ // top-level document, never an iframe.
214
+ window.location.assign(url);
215
+ });
216
+
217
+ return (
218
+ <Stack spacing={2}>
219
+ <ConnectionSummary
220
+ displayName={descriptor.displayName}
221
+ status={status}
222
+ connected={connected}
223
+ expiresAt={config?.expiresAt ?? null}
224
+ environment={environment}
225
+ />
226
+
227
+ {error ? <Alert severity="error">{error}</Alert> : null}
228
+
229
+ <ConnectionActions
230
+ displayName={descriptor.displayName}
231
+ connected={connected}
232
+ busy={busy}
233
+ onConnect={connect}
234
+ onDisconnect={() => setConfirmingDisconnect(true)}
235
+ />
236
+
237
+ <DisconnectDialog
238
+ open={confirmingDisconnect}
239
+ displayName={descriptor.displayName}
240
+ busy={busy === 'disconnect'}
241
+ onCancel={() => setConfirmingDisconnect(false)}
242
+ onConfirm={() =>
243
+ void run('disconnect', async () => {
244
+ await client.disconnectOAuth(descriptor.name);
245
+ setConfirmingDisconnect(false);
246
+ })
247
+ }
248
+ />
249
+ </Stack>
250
+ );
251
+ }
@@ -0,0 +1,387 @@
1
+ 'use client';
2
+
3
+ import { Alert, Stack } from '@mui/material';
4
+ import { useCallback, useEffect, useState, type ReactNode } from 'react';
5
+
6
+ import type {
7
+ CredentialFieldSpec,
8
+ MaskedProviderConfig,
9
+ PaymentEnvironment,
10
+ ProviderDescriptor,
11
+ } from '@12-apps/payments-backend';
12
+
13
+ import type { PaymentsSettingsClient } from '../client';
14
+
15
+ import { ConfirmCredentialSave, type PendingSave } from './ConfirmCredentialSave';
16
+ import { isConnected } from './connection-state';
17
+ import { CredentialField, DoneRow } from './CredentialFields';
18
+ import {
19
+ allRequiredStored,
20
+ fieldsWellFormed,
21
+ needsConfirmation,
22
+ saveLabel,
23
+ summaryOf,
24
+ } from './credential-rules';
25
+ import {
26
+ FormActions,
27
+ ProbeAlert,
28
+ ReverifyWarning,
29
+ type VerifyProbe,
30
+ } from './CredentialFormAlerts';
31
+
32
+ /**
33
+ * The `authMode: 'credentials'` half of the settings page — a provider whose
34
+ * connection is a FORM, rendered entirely from its `credentialSchema` so
35
+ * adding a vendor changes nothing here.
36
+ *
37
+ * Secrets are WRITE-ONLY: a stored value shows as a `••••1234` hint and is
38
+ * never echoed back, and leaving a field blank PRESERVES what is stored
39
+ * (which is what makes rotating one key without re-typing the others work).
40
+ */
41
+
42
+ /** Mutation runner shared by save/verify/enable: busy + error + refresh. */
43
+ function useSettingsAction(
44
+ onChanged: ((c: MaskedProviderConfig) => void) | undefined,
45
+ onSaved: () => void,
46
+ ) {
47
+ const [busy, setBusy] = useState<string | null>(null);
48
+ const [error, setError] = useState<string | null>(null);
49
+ // Generic in the action's result: `verify` returns a MaskedProviderConfig
50
+ // with the probe outcome attached, and narrowing it here would throw away
51
+ // the only field that reports what the probe actually found.
52
+ const run = useCallback(
53
+ async <T extends MaskedProviderConfig>(
54
+ label: string,
55
+ action: () => Promise<T>,
56
+ ): Promise<T | null> => {
57
+ setBusy(label);
58
+ setError(null);
59
+ try {
60
+ const next = await action();
61
+ onChanged?.(next);
62
+ onSaved();
63
+ return next;
64
+ } catch (err) {
65
+ setError(err instanceof Error ? err.message : String(err));
66
+ return null;
67
+ } finally {
68
+ setBusy(null);
69
+ }
70
+ },
71
+ [onChanged, onSaved],
72
+ );
73
+ return { busy, error, run };
74
+ }
75
+
76
+ interface ProviderFormProps {
77
+ descriptor: ProviderDescriptor;
78
+ config: MaskedProviderConfig | null;
79
+ client: PaymentsSettingsClient;
80
+ /**
81
+ * Which credential set is being edited.
82
+ *
83
+ * Owned by the PANEL, not by this form: the environment tabs sit at the top
84
+ * of the provider's card, above the walkthrough, because the choice frames
85
+ * everything below it — including which step the guide thinks you are on. A
86
+ * selector rendered halfway down, under the stepper it changes the meaning
87
+ * of, reads as a property of the fields rather than of the whole screen.
88
+ */
89
+ environment: PaymentEnvironment;
90
+ /** The owner has reopened a finished step. Owned by the panel — see there. */
91
+ editing: boolean;
92
+ onEditingChange: (editing: boolean) => void;
93
+ onChanged?: (config: MaskedProviderConfig) => void;
94
+ onSaved: () => void;
95
+ /**
96
+ * The stored credentials were REPLACED — a different account may now be on
97
+ * record.
98
+ *
99
+ * Distinct from `onSaved`, which merely means "refetch". Everything the owner
100
+ * has told us about the OLD account stops applying: "I switched Checkout
101
+ * Integrado on" was a claim about a specific InfinitePay account, and
102
+ * carrying it across to another one is how a store ends up on step 3 with a
103
+ * setting nobody has enabled there. The server already drops what IT knew
104
+ * (`applySaveCredentials` clears the status and the proof); this is the same
105
+ * rule for the one fact the server does not hold.
106
+ */
107
+ onCredentialsReplaced?: () => void;
108
+ /**
109
+ * The walkthrough, given this form's two pieces to place.
110
+ *
111
+ * A render prop rather than a rendered node because the two interleave: the
112
+ * finished credential belongs in the row strip above the open card, and the
113
+ * live field belongs INSIDE it, under the instruction that asks for it. Only
114
+ * this component knows which of the two it currently has.
115
+ */
116
+ renderGuide?: (slots: {
117
+ rows: ReactNode;
118
+ sectionFooter: ReactNode;
119
+ /** The owner reopened a finished step — the guide must go back to it. */
120
+ editing: boolean;
121
+ /**
122
+ * The environment ON SCREEN holds every required credential.
123
+ *
124
+ * Credentials are stored PER environment while `status`, the proof and the
125
+ * walkthrough's stage all describe the ACTIVE one — so a store that
126
+ * connected in Sandbox and then opened the Produção tab was shown a
127
+ * walkthrough reporting steps 1 and 2 done, over an empty field, for an
128
+ * environment that had never been connected at all. On the screen whose
129
+ * subject is which account gets paid, that is the worst possible place to
130
+ * imply work has been done.
131
+ */
132
+ stored: boolean;
133
+ }) => ReactNode;
134
+ }
135
+
136
+ /**
137
+ * "Salvar InfiniteTag", not "Salvar".
138
+ *
139
+ * A provider whose whole connection is one field can name it on the button, and
140
+ * that is worth doing on a step whose entire content is that field: the label
141
+ * then says what is about to be committed rather than merely that something is.
142
+ * Providers with several fields keep the plain verb — naming one of four would
143
+ * be a lie about what the button does.
144
+ */
145
+
146
+ /**
147
+ * The write, as the backend's preserve/clear/replace contract expects it.
148
+ *
149
+ * Every schema key is named on every save, and a key the owner did not touch
150
+ * arrives as `undefined` — which PRESERVES what is stored. That is what makes
151
+ * rotating one secret without re-typing the others work, and it is the reason
152
+ * the payload is built from the SCHEMA rather than from the typed values.
153
+ */
154
+ function savePayload(
155
+ descriptor: ProviderDescriptor,
156
+ environment: PaymentEnvironment,
157
+ values: Record<string, string>,
158
+ ) {
159
+ return {
160
+ environment,
161
+ fields: Object.fromEntries(
162
+ descriptor.credentialSchema.map((spec) => [spec.key, values[spec.key]]),
163
+ ),
164
+ };
165
+ }
166
+
167
+
168
+ /**
169
+ * Switching tabs retires the last verdict AND whatever was half-typed.
170
+ *
171
+ * A Sandbox probe says nothing about Produção, and Produção credentials
172
+ * mid-entry say nothing about Sandbox — carrying either across is how a screen
173
+ * comes to display a green result for the environment it is not looking at, on
174
+ * a page whose subject is which account gets paid.
175
+ */
176
+ function useRetireOnEnvironmentChange(
177
+ environment: PaymentEnvironment,
178
+ onEditingChange: (editing: boolean) => void,
179
+ setProbe: (probe: VerifyProbe | null) => void,
180
+ setValues: (values: Record<string, string>) => void,
181
+ ): void {
182
+ useEffect(() => {
183
+ setProbe(null);
184
+ setValues({});
185
+ onEditingChange(false);
186
+ }, [environment, onEditingChange, setProbe, setValues]);
187
+ }
188
+
189
+ function useCredentialForm(props: ProviderFormProps) {
190
+ const {
191
+ descriptor,
192
+ config,
193
+ client,
194
+ environment,
195
+ editing,
196
+ onEditingChange,
197
+ onChanged,
198
+ onSaved,
199
+ onCredentialsReplaced,
200
+ } = props;
201
+ const [values, setValues] = useState<Record<string, string>>({});
202
+ const [probe, setProbe] = useState<VerifyProbe | null>(null);
203
+ const [pending, setPending] = useState<PendingSave | null>(null);
204
+ const { busy, error, run } = useSettingsAction(onChanged, onSaved);
205
+
206
+ const probeNow = () =>
207
+ run('verify', () => client.verify(descriptor.name, environment)).then(
208
+ (verified) => verified && setProbe(verified.probe),
209
+ );
210
+
211
+ const save = () =>
212
+ run('save', () => client.saveCredentials(descriptor.name, savePayload(descriptor, environment, values)))
213
+ .then((next) => {
214
+ setPending(null);
215
+ if (!next) return undefined;
216
+ // The verdict described the credentials as they WERE; saving retires
217
+ // it, and so does everything the owner told us about the old account.
218
+ setValues({});
219
+ setProbe(null);
220
+ onEditingChange(false);
221
+ onCredentialsReplaced?.();
222
+ // …and the new ones are tested at once, which is the only moment the
223
+ // probe is useful: it reads STORED credentials, so it has nothing to
224
+ // say until a save has happened.
225
+ //
226
+ // Unless there is nothing to test. A save that CLEARS the field leaves
227
+ // no credential to probe, and asking anyway makes the adapter answer
228
+ // "Handle não configurado" — a store that has entered nothing has not
229
+ // failed at anything; it is simply NÃO VERIFICADO.
230
+ return allRequiredStored(descriptor, next, environment) ? probeNow() : undefined;
231
+ });
232
+
233
+ useRetireOnEnvironmentChange(environment, onEditingChange, setProbe, setValues);
234
+
235
+ return {
236
+ values,
237
+ probe,
238
+ pending,
239
+ busy,
240
+ error,
241
+ masked: config?.environments[environment] ?? {},
242
+ // Saving an untouched form is not a free no-op: `saveCredentials` resets
243
+ // the connection to UNVERIFIED, drops the proof and switches it OFF.
244
+ nothingEdited: Object.keys(values).length === 0,
245
+ valid: fieldsWellFormed(descriptor, values),
246
+ summary: summaryOf(descriptor, config, environment),
247
+ edit: (spec: string, value: string) => {
248
+ setValues((v) => ({ ...v, [spec]: value }));
249
+ setProbe(null); // an edited field retires the last verdict
250
+ },
251
+ /** Ask first when the value decides where the money goes, else just write. */
252
+ requestSave: () => {
253
+ const confirm = needsConfirmation(descriptor, config, environment, values);
254
+ if (confirm) setPending(confirm);
255
+ else void save();
256
+ },
257
+ save: () => void save(),
258
+ cancelSave: () => setPending(null),
259
+ reopen: () => onEditingChange(true),
260
+ verify: () => void probeNow(),
261
+ editing,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * The summary row to show INSTEAD of the fields, or null to show the fields.
267
+ *
268
+ * Four conditions have to hold at once, so they live together rather than as a
269
+ * boolean assembled beside the JSX: there has to be a summarisable value, the
270
+ * probe has to have reached the account with it, and the owner must be neither
271
+ * mid-edit nor holding unsaved changes — collapsing over either of those last
272
+ * two would silently discard what they had typed.
273
+ */
274
+ /**
275
+ * Does the environment currently on screen hold every required credential?
276
+ *
277
+ * Read from the MASKED view of that environment specifically — not from
278
+ * `config.status`, which describes the active one and would answer for the
279
+ * other tab.
280
+ */
281
+ function storedHere(
282
+ descriptor: ProviderDescriptor,
283
+ masked: Record<string, { configured: boolean } | undefined>,
284
+ ): boolean {
285
+ return descriptor.credentialSchema
286
+ .filter((spec) => spec.required)
287
+ .every((spec) => masked[spec.key]?.configured === true);
288
+ }
289
+
290
+ function collapsedSummary(
291
+ form: ReturnType<typeof useCredentialForm>,
292
+ connected: boolean,
293
+ ): { spec: CredentialFieldSpec; value: string } | null {
294
+ if (!form.summary || !connected) return null;
295
+ if (form.editing || !form.nothingEdited) return null;
296
+ return form.summary;
297
+ }
298
+
299
+
300
+ /**
301
+ * The live inputs for the step still owed, plus the one button that commits
302
+ * them — and, on a store that has already proved it can receive, the warning
303
+ * that saving will undo that.
304
+ *
305
+ * Its own component so `ProviderForm` stays about WHICH of the two shapes is on
306
+ * screen (the collapsed row, or this) rather than about what each contains.
307
+ */
308
+ function CredentialFields({
309
+ descriptor,
310
+ form,
311
+ proven,
312
+ }: {
313
+ descriptor: ProviderDescriptor;
314
+ form: ReturnType<typeof useCredentialForm>;
315
+ /** A real charge has landed through this connection — see `ReverifyWarning`. */
316
+ proven: boolean;
317
+ }) {
318
+ return (
319
+ <Stack spacing={2}>
320
+ {proven ? <ReverifyWarning /> : null}
321
+ {descriptor.credentialSchema.map((spec) => (
322
+ <CredentialField
323
+ key={spec.key}
324
+ spec={spec}
325
+ state={form.masked[spec.key]}
326
+ value={form.values[spec.key]}
327
+ onChange={(value) => form.edit(spec.key, value)}
328
+ />
329
+ ))}
330
+ <FormActions
331
+ busy={form.busy}
332
+ label={saveLabel(descriptor)}
333
+ disabled={form.nothingEdited || !form.valid}
334
+ onSave={form.requestSave}
335
+ />
336
+ </Stack>
337
+ );
338
+ }
339
+
340
+ export function ProviderForm(props: ProviderFormProps) {
341
+ const { descriptor, config, renderGuide } = props;
342
+ const form = useCredentialForm(props);
343
+
344
+ // Step 1 is finished when the credential is stored AND the probe has reached
345
+ // the account with it. Then the fields fold into one legible line: still
346
+ // checkable at a glance, no longer one keystroke from being changed.
347
+ const summary = collapsedSummary(form, isConnected(config));
348
+
349
+ const rows = summary ? (
350
+ <DoneRow
351
+ testId="payments-credential-summary"
352
+ label={summary.spec.label}
353
+ value={summary.value}
354
+ mono={summary.spec.mono}
355
+ onEdit={form.reopen}
356
+ />
357
+ ) : null;
358
+
359
+ const fields = summary ? null : <CredentialFields descriptor={descriptor} form={form} proven={Boolean(config?.chargeVerifiedAt)} />;
360
+
361
+ return (
362
+ <Stack spacing={2}>
363
+ {/* No guide (the host supplied none): the form stands on its own, which
364
+ is what every caller saw before the walkthrough could hold it. */}
365
+ {renderGuide
366
+ ? renderGuide({
367
+ rows,
368
+ sectionFooter: fields,
369
+ editing: form.editing && !summary,
370
+ stored: storedHere(descriptor, form.masked),
371
+ })
372
+ : (rows ?? fields)}
373
+
374
+ {form.error ? <Alert severity="error">{form.error}</Alert> : null}
375
+ {form.probe ? (
376
+ <ProbeAlert probe={form.probe} busy={form.busy !== null} onRetry={form.verify} />
377
+ ) : null}
378
+
379
+ <ConfirmCredentialSave
380
+ pending={form.pending}
381
+ busy={form.busy !== null}
382
+ onCancel={form.cancelSave}
383
+ onConfirm={form.save}
384
+ />
385
+ </Stack>
386
+ );
387
+ }