@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,379 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Alert,
5
+ Box,
6
+ Button,
7
+ CircularProgress,
8
+ FormControlLabel,
9
+ Radio,
10
+ RadioGroup,
11
+ Stack,
12
+ TextField,
13
+ Typography,
14
+ } from '@mui/material';
15
+ import { useCallback, useEffect, useState } from 'react';
16
+
17
+ import type { ClientChargeView, CustomerInfo, Money } from '@12-apps/payments-backend';
18
+
19
+ import type { ClientPaymentsConfig, PaymentsClient } from '../client';
20
+ import { useChargeStatus, useCreateCharge, usePaymentsClient } from '../context';
21
+
22
+ /**
23
+ * Plug-and-play checkout payment step — the reusable equivalent of the
24
+ * buyer-facing payment screen. Renders the right flow for the merchant's
25
+ * ACTIVE provider by switching on its client config and the created charge:
26
+ *
27
+ * - PIX: create → show copy-paste QR payload (+ image when the provider
28
+ * returns one) → poll until PAID → `onPaid`
29
+ * - CARD: collect via a host-injected `tokenizeCard` (each provider's
30
+ * tokenization differs — Stripe SDK, public-key encryption, ...); only
31
+ * the resulting TOKEN reaches the backend
32
+ * - REDIRECT (hosted checkout, e.g. InfinitePay): send the buyer to
33
+ * `hostedCheckoutUrl`, poll on return
34
+ *
35
+ * Must be rendered inside a `<PaymentsProvider>`. The host owns totals,
36
+ * order creation, and what happens on `onPaid` — this component owns only
37
+ * the payment interaction.
38
+ */
39
+ export interface CardFormValues {
40
+ number: string;
41
+ holder: string;
42
+ expiry: string;
43
+ cvv: string;
44
+ }
45
+
46
+ /** A provider-vaulted card the buyer may reuse ("Mastercard •••• 7599"). */
47
+ export interface SavedCardOption {
48
+ savedCardToken: string;
49
+ brand: string;
50
+ last4: string;
51
+ /** e.g. "02/2034" — shown as "Validade 02/2034". */
52
+ expiry?: string;
53
+ }
54
+
55
+ export interface CheckoutPaymentProps {
56
+ /**
57
+ * Opaque host-side handle for what is being paid (cart/order id). Sent as
58
+ * a LOOKUP KEY only — the server resolves the authoritative amount and
59
+ * reference from its own records, never from this component.
60
+ */
61
+ reference: string;
62
+ /** Display total. Shown to the buyer; the server recomputes what is charged. */
63
+ amount: Money;
64
+ customer: CustomerInfo;
65
+ /**
66
+ * Provider-specific client-side tokenization, injected by the host using
67
+ * `config.tokenization`/`config.publicKey` (SDK or public-key flows).
68
+ * Required for providers whose config declares CARD support.
69
+ */
70
+ tokenizeCard?: (values: CardFormValues, config: ClientPaymentsConfig) => Promise<string>;
71
+ /** Provider-vaulted cards for one-tap reuse (host loads them). */
72
+ savedCards?: SavedCardOption[];
73
+ onPaid: (charge: ClientChargeView) => void;
74
+ onFailed?: (charge: ClientChargeView) => void;
75
+ }
76
+
77
+ function formatBRL(amount: Money): string {
78
+ return (amount.amountCents / 100).toLocaleString('pt-BR', {
79
+ style: 'currency',
80
+ currency: amount.currency,
81
+ });
82
+ }
83
+
84
+ function firstError(...messages: (string | null | undefined)[]): string | null {
85
+ for (const message of messages) if (message) return message;
86
+ return null;
87
+ }
88
+
89
+ /** Card charges can settle synchronously on create (no polling needed). */
90
+ function isFinalOnCreate(charge: ClientChargeView): boolean {
91
+ return charge.status !== 'PENDING' && charge.status !== 'AUTHORIZED';
92
+ }
93
+
94
+ /** Load the merchant's client-safe payment config once. */
95
+ function useClientConfig(client: PaymentsClient) {
96
+ const [config, setConfig] = useState<ClientPaymentsConfig | null | undefined>(undefined);
97
+ const [error, setError] = useState<string | null>(null);
98
+ useEffect(() => {
99
+ client
100
+ .getConfig()
101
+ .then(setConfig)
102
+ .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
103
+ }, [client]);
104
+ return { config, error };
105
+ }
106
+
107
+ /** Fire onPaid/onFailed exactly when the charge reaches its outcome. */
108
+ function useChargeOutcome(
109
+ current: ClientChargeView | null,
110
+ settled: boolean,
111
+ onPaid: (charge: ClientChargeView) => void,
112
+ onFailed?: (charge: ClientChargeView) => void,
113
+ ) {
114
+ useEffect(() => {
115
+ if (!current) return;
116
+ if (current.status === 'PAID') onPaid(current);
117
+ else if (settled || isFinalOnCreate(current)) onFailed?.(current);
118
+ }, [current, settled, onPaid, onFailed]);
119
+ }
120
+
121
+ function PixPanel({ charge }: { charge: ClientChargeView }) {
122
+ const [copied, setCopied] = useState(false);
123
+ if (!charge.pix) return null;
124
+ return (
125
+ <Stack spacing={1} alignItems="flex-start">
126
+ {charge.pix.qrImageUrl ? (
127
+ <Box component="img" src={charge.pix.qrImageUrl} alt="QR Code PIX" sx={{ width: 220 }} />
128
+ ) : null}
129
+ <TextField fullWidth multiline size="small" label="PIX copia e cola" value={charge.pix.qrText} />
130
+ <Button
131
+ size="small"
132
+ onClick={() => {
133
+ void navigator.clipboard.writeText(charge.pix?.qrText ?? '').then(() => setCopied(true));
134
+ }}
135
+ >
136
+ {copied ? 'Copiado!' : 'Copiar código'}
137
+ </Button>
138
+ <Stack direction="row" spacing={1} alignItems="center">
139
+ <CircularProgress size={16} />
140
+ <Typography variant="body2">Aguardando pagamento…</Typography>
141
+ </Stack>
142
+ </Stack>
143
+ );
144
+ }
145
+
146
+ function CardPanel({ disabled, onSubmit }: { disabled: boolean; onSubmit: (values: CardFormValues) => void }) {
147
+ const [values, setValues] = useState<CardFormValues>({ number: '', holder: '', expiry: '', cvv: '' });
148
+ const field = (key: keyof CardFormValues, label: string, width?: number) => (
149
+ <TextField
150
+ size="small"
151
+ label={label}
152
+ value={values[key]}
153
+ sx={width ? { width } : undefined}
154
+ onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
155
+ />
156
+ );
157
+ return (
158
+ <Stack spacing={2}>
159
+ {field('number', 'Número do cartão')}
160
+ {field('holder', 'Nome impresso no cartão')}
161
+ <Stack direction="row" spacing={2}>
162
+ {field('expiry', 'Validade (MM/AA)', 140)}
163
+ {field('cvv', 'CVV', 100)}
164
+ </Stack>
165
+ <Button variant="contained" disabled={disabled} onClick={() => onSubmit(values)}>
166
+ Pagar com cartão
167
+ </Button>
168
+ </Stack>
169
+ );
170
+ }
171
+
172
+ interface CardSectionProps {
173
+ amount: Money;
174
+ savedCards: SavedCardOption[];
175
+ disabled: boolean;
176
+ onNewCard: (values: CardFormValues) => void;
177
+ onSavedCard: (card: SavedCardOption) => void;
178
+ }
179
+
180
+ /** "Pague com cartão": saved cards as radios + "Novo cartão" fallback. */
181
+ function CardSection({ amount, savedCards, disabled, onNewCard, onSavedCard }: CardSectionProps) {
182
+ const [selected, setSelected] = useState(savedCards[0]?.savedCardToken ?? 'new');
183
+ const chosen = savedCards.find((c) => c.savedCardToken === selected);
184
+ if (savedCards.length === 0) return <CardPanel disabled={disabled} onSubmit={onNewCard} />;
185
+ return (
186
+ <Stack spacing={2}>
187
+ <Typography variant="subtitle2">Pague com cartão</Typography>
188
+ <RadioGroup value={selected} onChange={(_, v) => setSelected(v)}>
189
+ {savedCards.map((card) => (
190
+ <FormControlLabel
191
+ key={card.savedCardToken}
192
+ value={card.savedCardToken}
193
+ control={<Radio />}
194
+ label={`${card.brand} •••• ${card.last4}${card.expiry ? ` — Validade ${card.expiry}` : ''}`}
195
+ />
196
+ ))}
197
+ <FormControlLabel value="new" control={<Radio />} label="Novo cartão — inserir outro cartão" />
198
+ </RadioGroup>
199
+ {chosen ? (
200
+ <Button variant="contained" disabled={disabled} onClick={() => onSavedCard(chosen)}>
201
+ Pagar {formatBRL(amount)}
202
+ </Button>
203
+ ) : (
204
+ <CardPanel disabled={disabled} onSubmit={onNewCard} />
205
+ )}
206
+ </Stack>
207
+ );
208
+ }
209
+
210
+ interface MethodChooserProps {
211
+ config: ClientPaymentsConfig;
212
+ amount: Money;
213
+ savedCards: SavedCardOption[];
214
+ loading: boolean;
215
+ startPix: () => void;
216
+ startCard: (values: CardFormValues) => void;
217
+ startSavedCard: (card: SavedCardOption) => void;
218
+ }
219
+
220
+ /** One "Forma de pagamento" option card (PIX / Cartão). */
221
+ function MethodCard(props: { title: string; subtitle: string; selected: boolean; onClick: () => void }) {
222
+ return (
223
+ <Button
224
+ variant="outlined"
225
+ onClick={props.onClick}
226
+ sx={{ flex: 1, justifyContent: 'flex-start', textAlign: 'left', borderWidth: props.selected ? 2 : 1 }}
227
+ >
228
+ <Stack alignItems="flex-start">
229
+ <Typography variant="subtitle2">{props.title}</Typography>
230
+ <Typography variant="caption" color="text.secondary">
231
+ {props.subtitle}
232
+ </Typography>
233
+ </Stack>
234
+ </Button>
235
+ );
236
+ }
237
+
238
+ /** Pre-charge phase: "Forma de pagamento" method cards, then the flow. */
239
+ function MethodChooser(props: MethodChooserProps) {
240
+ const { config, amount, savedCards, loading, startPix, startCard, startSavedCard } = props;
241
+ const [method, setMethod] = useState<'PIX' | 'CARD'>('PIX');
242
+ if (config.tokenization === 'REDIRECT') {
243
+ return (
244
+ <Button variant="contained" disabled={loading} onClick={startPix}>
245
+ Continuar para o pagamento
246
+ </Button>
247
+ );
248
+ }
249
+ return (
250
+ <>
251
+ <Typography variant="subtitle2">Forma de pagamento</Typography>
252
+ <Stack direction="row" spacing={2}>
253
+ <MethodCard title="PIX" subtitle="Aprovação imediata" selected={method === 'PIX'} onClick={() => setMethod('PIX')} />
254
+ <MethodCard title="Cartão" subtitle="Crédito à vista" selected={method === 'CARD'} onClick={() => setMethod('CARD')} />
255
+ </Stack>
256
+ {method === 'PIX' ? (
257
+ <Button variant="contained" disabled={loading} onClick={startPix}>
258
+ Gerar QR Code PIX
259
+ </Button>
260
+ ) : (
261
+ <CardSection
262
+ amount={amount}
263
+ savedCards={savedCards}
264
+ disabled={loading}
265
+ onNewCard={startCard}
266
+ onSavedCard={startSavedCard}
267
+ />
268
+ )}
269
+ </>
270
+ );
271
+ }
272
+
273
+ /** Poll target: only charges that can still change server-side. */
274
+ function pollRefOf(charge: ClientChargeView | null) {
275
+ if (!charge || isFinalOnCreate(charge)) return null;
276
+ return { provider: charge.provider, providerChargeId: charge.providerChargeId };
277
+ }
278
+
279
+ interface CheckoutGateProps {
280
+ errorMessage: string | null;
281
+ config: ClientPaymentsConfig | null | undefined;
282
+ }
283
+
284
+ /** Error / loading / payments-disabled gates ahead of the payment UI. */
285
+ function checkoutGate({ errorMessage, config }: CheckoutGateProps) {
286
+ if (errorMessage) return <Alert severity="error">{errorMessage}</Alert>;
287
+ if (config === undefined) return <CircularProgress data-testid="checkout-payment-loading" />;
288
+ if (config === null) {
289
+ return <Alert severity="warning">Esta loja ainda não aceita pagamentos online.</Alert>;
290
+ }
291
+ return null;
292
+ }
293
+
294
+ /** In-flight phase: the created charge decides what the buyer sees. */
295
+ function ChargePhase({ charge, amount }: { charge: ClientChargeView; amount: Money }) {
296
+ if (charge.hostedCheckoutUrl && charge.status === 'PENDING') {
297
+ return (
298
+ <Stack spacing={2}>
299
+ <Typography>Você será direcionado para concluir o pagamento com segurança.</Typography>
300
+ <Button variant="contained" href={charge.hostedCheckoutUrl}>
301
+ Pagar {formatBRL(amount)}
302
+ </Button>
303
+ </Stack>
304
+ );
305
+ }
306
+ if (charge.method === 'PIX' && charge.status === 'PENDING') return <PixPanel charge={charge} />;
307
+ return null;
308
+ }
309
+
310
+ export function CheckoutPayment(props: CheckoutPaymentProps) {
311
+ const { reference, amount, customer, tokenizeCard, savedCards = [], onPaid, onFailed } = props;
312
+ const client = usePaymentsClient();
313
+ const { config, error: configError } = useClientConfig(client);
314
+ const [tokenizeError, setTokenizeError] = useState<string | null>(null);
315
+ const { charge, loading, create, error: createError } = useCreateCharge();
316
+ const { charge: live, settled } = useChargeStatus(pollRefOf(charge));
317
+ const current = live ?? charge;
318
+ useChargeOutcome(current, settled, onPaid, onFailed);
319
+
320
+ const startPix = useCallback(
321
+ () => void create({ method: 'PIX', customer, orderRef: reference }),
322
+ [create, reference, customer],
323
+ );
324
+
325
+ const startCard = useCallback(
326
+ async (values: CardFormValues) => {
327
+ if (!config || !tokenizeCard) {
328
+ setTokenizeError('Pagamento com cartão indisponível.');
329
+ return;
330
+ }
331
+ try {
332
+ const token = await tokenizeCard(values, config);
333
+ await create({
334
+ method: 'CARD',
335
+ customer,
336
+ orderRef: reference,
337
+ card: { token, holder: values.holder },
338
+ });
339
+ } catch (err) {
340
+ setTokenizeError(err instanceof Error ? err.message : String(err));
341
+ }
342
+ },
343
+ [config, tokenizeCard, create, reference, customer],
344
+ );
345
+
346
+ const startSavedCard = useCallback(
347
+ (card: SavedCardOption) =>
348
+ void create({
349
+ method: 'CARD',
350
+ customer,
351
+ orderRef: reference,
352
+ card: { savedCardToken: card.savedCardToken },
353
+ }),
354
+ [create, reference, customer],
355
+ );
356
+
357
+ const errorMessage = firstError(configError, tokenizeError, createError && createError.message);
358
+ const gate = checkoutGate({ errorMessage, config });
359
+ if (gate || config === undefined || config === null) return gate;
360
+
361
+ return (
362
+ <Stack spacing={2}>
363
+ <Typography variant="h6">Total: {formatBRL(amount)}</Typography>
364
+ {current ? (
365
+ <ChargePhase charge={current} amount={amount} />
366
+ ) : (
367
+ <MethodChooser
368
+ config={config}
369
+ amount={amount}
370
+ savedCards={savedCards}
371
+ loading={loading}
372
+ startPix={startPix}
373
+ startCard={(values) => void startCard(values)}
374
+ startSavedCard={startSavedCard}
375
+ />
376
+ )}
377
+ </Stack>
378
+ );
379
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Box,
5
+ Button,
6
+ Dialog,
7
+ DialogActions,
8
+ DialogContent,
9
+ DialogTitle,
10
+ Typography,
11
+ } from '@mui/material';
12
+
13
+ import type { CredentialFieldSpec } from '@12-apps/payments-backend';
14
+
15
+ /**
16
+ * The last look at the value that decides who gets paid.
17
+ *
18
+ * Two real R$ 1,01 payments were lost during this flow's development, both to
19
+ * things that could be tested and fixed. A mistyped InfiniteTag is the failure
20
+ * with no such recourse: the charge succeeds, the link works, the buyer is
21
+ * happy, and the money is in a stranger's account with nothing on our side able
22
+ * to reverse it. There is no checksum to catch it, no confirmation email, and
23
+ * the store finds out at the end of the month.
24
+ *
25
+ * So the one intervention available is to show the value back, large, in the
26
+ * face it will be read in, and say what saving it means — after the owner has
27
+ * decided to save and before the write happens. This is the whole of the
28
+ * dialog: no new information, one more reading.
29
+ */
30
+
31
+ export interface PendingSave {
32
+ spec: CredentialFieldSpec;
33
+ value: string;
34
+ }
35
+
36
+ export function ConfirmCredentialSave({
37
+ pending,
38
+ busy,
39
+ onCancel,
40
+ onConfirm,
41
+ }: {
42
+ /** Null while nothing is awaiting confirmation. */
43
+ pending: PendingSave | null;
44
+ busy: boolean;
45
+ onCancel: () => void;
46
+ onConfirm: () => void;
47
+ }) {
48
+ return (
49
+ <Dialog
50
+ open={pending !== null}
51
+ onClose={onCancel}
52
+ maxWidth="xs"
53
+ fullWidth
54
+ data-testid="payments-confirm-credential"
55
+ aria-labelledby="payments-confirm-credential-title"
56
+ >
57
+ <DialogTitle id="payments-confirm-credential-title">
58
+ Confirme para onde vai o dinheiro
59
+ </DialogTitle>
60
+ <DialogContent>
61
+ <Typography variant="body2" color="text.secondary">
62
+ Todo pagamento recebido por esta loja será depositado na conta desta{' '}
63
+ {pending?.spec.label ?? 'credencial'}:
64
+ </Typography>
65
+ <Box
66
+ data-testid="payments-confirm-credential-value"
67
+ sx={{
68
+ my: 2,
69
+ py: 2,
70
+ px: 1,
71
+ textAlign: 'center',
72
+ fontFamily: 'monospace',
73
+ fontSize: '1.5rem',
74
+ letterSpacing: '0.12em',
75
+ wordBreak: 'break-all',
76
+ borderRadius: 1,
77
+ bgcolor: 'action.hover',
78
+ }}
79
+ >
80
+ {pending?.value}
81
+ </Box>
82
+ <Typography variant="caption" color="text.secondary">
83
+ Um valor errado envia os pagamentos desta loja para outra pessoa, e não há como reverter.
84
+ </Typography>
85
+ </DialogContent>
86
+ <DialogActions>
87
+ {/*
88
+ Cancel is the wide, plain, first-reached option and confirm carries
89
+ the specific verb: an owner who is not sure should find it easier to
90
+ go back and check than to press on.
91
+ */}
92
+ <Button onClick={onCancel} disabled={busy} data-testid="payments-confirm-credential-cancel">
93
+ Voltar e revisar
94
+ </Button>
95
+ <Button
96
+ variant="contained"
97
+ onClick={onConfirm}
98
+ disabled={busy}
99
+ data-testid="payments-confirm-credential-confirm"
100
+ >
101
+ É essa, salvar
102
+ </Button>
103
+ </DialogActions>
104
+ </Dialog>
105
+ );
106
+ }
@@ -0,0 +1,158 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, Stack, TextField, Typography } from '@mui/material';
4
+
5
+ import type { CredentialFieldSpec, MaskedFieldState } from '@12-apps/payments-backend';
6
+
7
+ /**
8
+ * The schema-driven credential inputs, and the one-line summary a finished
9
+ * step collapses into.
10
+ *
11
+ * Split out of `ProviderCredentialForm` so that file stays about the ACTIONS
12
+ * (save, probe, confirm) rather than about rendering a field.
13
+ */
14
+
15
+ interface FieldPresentation {
16
+ type: 'text' | 'password';
17
+ value: string;
18
+ required: boolean;
19
+ placeholder?: string;
20
+ helperText?: string;
21
+ }
22
+
23
+ /** Write-only secret field: masked hint as placeholder, value never echoed. */
24
+ function secretPresentation(
25
+ state: MaskedFieldState | undefined,
26
+ value: string | undefined,
27
+ required: boolean,
28
+ ): FieldPresentation {
29
+ const configured = state?.configured ?? false;
30
+ return {
31
+ type: 'password',
32
+ value: value ?? '',
33
+ required: required && !configured,
34
+ placeholder: configured ? (state?.hint ?? '') : undefined,
35
+ helperText: configured ? 'Configurado — deixe em branco para manter o valor atual.' : undefined,
36
+ };
37
+ }
38
+
39
+ function fieldPresentation(
40
+ spec: CredentialFieldSpec,
41
+ state: MaskedFieldState | undefined,
42
+ value: string | undefined,
43
+ ): FieldPresentation {
44
+ if (spec.secret) return secretPresentation(state, value, spec.required);
45
+ const configured = state?.configured ?? false;
46
+ return {
47
+ type: 'text',
48
+ value: value ?? state?.hint ?? '',
49
+ required: spec.required && !configured,
50
+ placeholder: spec.placeholder,
51
+ helperText: spec.helperText,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * A monospace, letter-spaced face for values that must be checked character by
57
+ * character. The whole reason InfinitePay's handle carries `mono` is that a
58
+ * wrong one is not rejected — it is a different person's account.
59
+ */
60
+ const MONO_INPUT = {
61
+ fontFamily: 'monospace',
62
+ letterSpacing: '0.08em',
63
+ } as const;
64
+
65
+ interface CredentialFieldProps {
66
+ spec: CredentialFieldSpec;
67
+ state: MaskedFieldState | undefined;
68
+ value: string | undefined;
69
+ onChange: (value: string) => void;
70
+ }
71
+
72
+ /** One schema-driven form field. Secrets are write-only: hint, never value. */
73
+ export function CredentialField({ spec, state, value, onChange }: CredentialFieldProps) {
74
+ const presentation = fieldPresentation(spec, state, value);
75
+ return (
76
+ <TextField
77
+ size="small"
78
+ label={spec.label}
79
+ {...presentation}
80
+ slotProps={spec.mono ? { htmlInput: { sx: MONO_INPUT, spellCheck: false } } : undefined}
81
+ onChange={(e) => onChange(e.target.value)}
82
+ />
83
+ );
84
+ }
85
+
86
+ /**
87
+ * What a completed step collapses to.
88
+ *
89
+ * A form that keeps every finished step expanded makes the owner re-read three
90
+ * cards to find the one thing still outstanding, and — worse for this screen —
91
+ * leaves the field that decides where the money goes sitting there editable,
92
+ * one stray keystroke from being changed by someone who came back to check
93
+ * something else. Collapsed, the value is still legible (that IS the check
94
+ * they came for) and altering it is a deliberate act.
95
+ */
96
+ /**
97
+ * The parenthetical in a field label — "InfiniteTag ($usuario)" — tells you how
98
+ * to TYPE the value. Beside the value itself it is answered by the thing it
99
+ * sits next to, so the summary row shows the bare name.
100
+ */
101
+ // Drops a trailing "(...)" qualifier from a field label. Done by scanning
102
+ // rather than with /\s*\([^)]*\)\s*$/, which is polynomial-time on labels made
103
+ // of many spaces or many open parens (CodeQL: polynomial regular expression
104
+ // used on uncontrolled data) — the label is supplied by the caller.
105
+ function stripTrailingParenthetical(label: string): string {
106
+ const trimmed = label.trimEnd();
107
+ if (!trimmed.endsWith(')')) return trimmed;
108
+
109
+ const open = trimmed.lastIndexOf('(');
110
+ if (open === -1) return trimmed;
111
+
112
+ return trimmed.slice(0, open).trimEnd();
113
+ }
114
+
115
+ export function DoneRow({
116
+ label,
117
+ value,
118
+ mono,
119
+ onEdit,
120
+ editLabel = 'Alterar',
121
+ testId,
122
+ }: {
123
+ label: string;
124
+ value: string;
125
+ mono?: boolean;
126
+ onEdit: () => void;
127
+ editLabel?: string;
128
+ testId: string;
129
+ }) {
130
+ return (
131
+ <Stack
132
+ direction="row"
133
+ spacing={1.5}
134
+ alignItems="center"
135
+ data-testid={testId}
136
+ sx={{ border: 1, borderColor: 'divider', borderRadius: 1, px: 2, py: 1.25 }}
137
+ >
138
+ <Box aria-hidden sx={{ color: 'success.main', fontWeight: 700 }}>
139
+
140
+ </Box>
141
+ <Typography variant="body2" color="text.secondary">
142
+ {stripTrailingParenthetical(label)}
143
+ </Typography>
144
+ <Typography variant="body2" sx={mono ? MONO_INPUT : undefined}>
145
+ {value}
146
+ </Typography>
147
+ <Box sx={{ flexGrow: 1 }} />
148
+ <Button
149
+ size="small"
150
+ onClick={onEdit}
151
+ data-testid={`${testId}-edit`}
152
+ sx={{ textTransform: 'none' }}
153
+ >
154
+ {editLabel}
155
+ </Button>
156
+ </Stack>
157
+ );
158
+ }