@12-apps/payments-frontend 2.0.0 → 3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "storybook:build": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@12-apps/payments-backend": "^3.0.0",
20
+ "@12-apps/payments-backend": "^4.0.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -60,6 +60,36 @@ export interface PaymentsClientOptions {
60
60
  fetchImpl?: typeof fetch;
61
61
  }
62
62
 
63
+ /**
64
+ * The sentence a failed request carries, rather than the response body.
65
+ *
66
+ * The body is a JSON envelope, and throwing it verbatim put
67
+ * `{"error":"CredentialsError","message":"No platform OAuth application
68
+ * credentials configured for stripe/SANDBOX"}` on a store owner's settings
69
+ * screen, inside a red alert, as the entire explanation of why a button did
70
+ * nothing. Unwrapping `message` is the floor: it is the only field written to
71
+ * be read, and an error class name is never the owner's problem.
72
+ *
73
+ * That message may still be developer English — the surfaces that can do better
74
+ * translate it (see `ProviderConnection`). This function's job is only to stop
75
+ * shipping the envelope.
76
+ */
77
+ function failureMessage(body: string, status: number): string {
78
+ const fallback = `Payments request failed (${status})`;
79
+ if (!body) return fallback;
80
+ try {
81
+ const parsed: unknown = JSON.parse(body);
82
+ if (parsed !== null && typeof parsed === 'object' && 'message' in parsed) {
83
+ const { message } = parsed as { message?: unknown };
84
+ if (typeof message === 'string' && message.trim() !== '') return message;
85
+ }
86
+ } catch {
87
+ // Not JSON — a proxy's HTML error page, a gateway timeout. The raw text is
88
+ // still the most informative thing available.
89
+ }
90
+ return body;
91
+ }
92
+
63
93
  function makeRequest(options: PaymentsClientOptions) {
64
94
  const fetchImpl = options.fetchImpl ?? fetch;
65
95
  return async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -69,7 +99,7 @@ function makeRequest(options: PaymentsClientOptions) {
69
99
  });
70
100
  if (!res.ok) {
71
101
  const body = await res.text().catch(() => '');
72
- throw new PaymentsClientError(res.status, body || `Payments request failed (${res.status})`);
102
+ throw new PaymentsClientError(res.status, failureMessage(body, res.status));
73
103
  }
74
104
  return (await res.json()) as T;
75
105
  };
@@ -0,0 +1,234 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Box,
5
+ Button,
6
+ CircularProgress,
7
+ Dialog,
8
+ DialogActions,
9
+ DialogContent,
10
+ DialogContentText,
11
+ DialogTitle,
12
+ Stack,
13
+ Typography,
14
+ } from '@mui/material';
15
+ import type { ReactNode } from 'react';
16
+
17
+ import type { ConnectedOAuthAccount, PaymentEnvironment } from '@12-apps/payments-backend';
18
+
19
+ import { BTN_PRIMARY_SX, BTN_SECONDARY_SX, LINKISH_SX, T } from './panel-tokens';
20
+
21
+ /**
22
+ * The connect card's own pieces: what a connection IS, what authorizing
23
+ * involves, and what removing it costs.
24
+ *
25
+ * Split from `ProviderConnection`, which is about the connection's STATE
26
+ * MACHINE — one in-flight action, its failure, the grant's expiry. These four
27
+ * are about what the owner reads while deciding, and keeping them here is what
28
+ * holds both files inside the size gate.
29
+ */
30
+
31
+ /**
32
+ * What this connection IS, as four labelled facts.
33
+ *
34
+ * A connected store's screen used to answer "connected?" and stop. The
35
+ * questions an owner actually returns with are which account, how it was
36
+ * connected, which environment, and what it can take — and every one of them
37
+ * was either absent or buried in a sentence. Read as a grid they are checkable
38
+ * at a glance, which is the whole reason to come back to this screen at all.
39
+ */
40
+ export function ConnectionFacts({
41
+ environment,
42
+ account,
43
+ displayName,
44
+ }: {
45
+ environment: PaymentEnvironment;
46
+ account: ConnectedOAuthAccount | null;
47
+ displayName: string;
48
+ }) {
49
+ // A fact's testid names its VALUE, not its row: the environment is asserted
50
+ // with an exact `toHaveText`, so hanging the id on the grid — or even on the
51
+ // row, which also carries the "AMBIENTE" label — makes that assertion read
52
+ // the whole card. Every consumer of this id wants the one string.
53
+ const facts: { key: string; value: string; testId?: string }[] = [
54
+ { key: 'Conta', value: account?.accountLabel ?? account?.accountId ?? `Conta ${displayName}` },
55
+ { key: 'Conexão', value: `Autorizada no ${displayName}` },
56
+ {
57
+ key: 'Ambiente',
58
+ value: environment === 'PRODUCTION' ? 'Produção' : 'Sandbox (testes)',
59
+ testId: 'payments-connected-environment',
60
+ },
61
+ ];
62
+ if (account?.connectedAt) {
63
+ facts.push({
64
+ key: 'Conectada em',
65
+ value: new Date(account.connectedAt).toLocaleString('pt-BR'),
66
+ });
67
+ }
68
+ return (
69
+ <Box
70
+ sx={{
71
+ display: 'grid',
72
+ gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
73
+ gap: '12px 22px',
74
+ }}
75
+ >
76
+ {facts.map(({ key, value, testId }) => (
77
+ <Stack key={key} spacing={0.4} sx={{ borderLeft: `2px solid ${T.line2}`, pl: '11px' }}>
78
+ <Typography
79
+ sx={{
80
+ fontSize: '11px',
81
+ letterSpacing: '.05em',
82
+ textTransform: 'uppercase',
83
+ color: T.ink4,
84
+ fontWeight: 700,
85
+ }}
86
+ >
87
+ {key}
88
+ </Typography>
89
+ <Typography
90
+ data-testid={testId}
91
+ sx={{ fontSize: '13px', color: T.ink2, fontFamily: T.mono }}
92
+ >
93
+ {value}
94
+ </Typography>
95
+ </Stack>
96
+ ))}
97
+ </Box>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * What authorizing actually involves, in three numbered steps.
103
+ *
104
+ * The card used to be one sentence and a button, which asks the owner to click
105
+ * something that navigates away from their store to a site they will be asked
106
+ * to log into. Numbering the three moments — sign in, authorize, come back —
107
+ * makes the trip finite and says the one thing that most reduces hesitation:
108
+ * you end up back here.
109
+ */
110
+ export function ConnectSteps({ displayName }: { displayName: string }) {
111
+ const steps = [
112
+ `Entre na sua conta ${displayName} — dá para criar uma na hora, se ainda não tiver.`,
113
+ 'Autorize o acesso na tela do provedor.',
114
+ 'Você volta para cá com a conta conectada.',
115
+ ];
116
+ return (
117
+ <Stack component="ol" spacing={1.1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
118
+ {steps.map((text, index) => (
119
+ <Stack component="li" key={text} direction="row" gap="9px">
120
+ <Box
121
+ aria-hidden
122
+ sx={{
123
+ width: 19,
124
+ height: 19,
125
+ borderRadius: '50%',
126
+ background: T.brandSoft,
127
+ color: T.brandInk,
128
+ fontSize: '11px',
129
+ fontWeight: 700,
130
+ display: 'grid',
131
+ placeItems: 'center',
132
+ flex: '0 0 auto',
133
+ mt: '1px',
134
+ }}
135
+ >
136
+ {index + 1}
137
+ </Box>
138
+ <Typography sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.5 }}>{text}</Typography>
139
+ </Stack>
140
+ ))}
141
+ </Stack>
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Confirmation for Desconectar, which is destructive and irreversible from
147
+ * here: it revokes the grant at the provider, so the store stops being able to
148
+ * charge immediately and getting back requires the owner to authorize again on
149
+ * the provider's site. It also sat one careless click from "Reconectar".
150
+ */
151
+ export function DisconnectDialog(props: {
152
+ open: boolean;
153
+ displayName: string;
154
+ busy: boolean;
155
+ /** The store is live on this provider — removing it stops checkout NOW. */
156
+ receiving: boolean;
157
+ onCancel: () => void;
158
+ onConfirm: () => void;
159
+ /** The softer path: keep the connection, stop taking orders through it. */
160
+ onPauseInstead?: () => void;
161
+ }) {
162
+ const { displayName, receiving } = props;
163
+ return (
164
+ <Dialog open={props.open} onClose={props.onCancel} data-testid="payments-disconnect-confirm">
165
+ <DialogTitle sx={{ fontSize: '16.5px', fontWeight: 700, letterSpacing: '-.01em', pb: '8px' }}>
166
+ Remover a conexão com {displayName}?
167
+ </DialogTitle>
168
+ <DialogContent sx={{ pb: '4px' }}>
169
+ <DialogContentText sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.55 }}>
170
+ {receiving
171
+ ? 'A loja para de receber na hora e fica sem provedor ativo — pedidos novos não conseguem ser pagos até você conectar outro.'
172
+ : 'Esta conexão sai da loja. Você pode conectar de novo depois.'}
173
+ </DialogContentText>
174
+ {/* The consequences, itemised. A single paragraph makes the reversible
175
+ facts and the irreversible one weigh the same, and the one that
176
+ costs money is the one an owner skims past. */}
177
+ <Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, mt: '14px', p: 0 }}>
178
+ {receiving ? (
179
+ <Consequence tone="bad">Pedidos novos deixam de ser cobrados imediatamente.</Consequence>
180
+ ) : null}
181
+ <Consequence>
182
+ {`A autorização é revogada no ${displayName}. Sua conta e seu histórico continuam lá, intactos.`}
183
+ </Consequence>
184
+ <Consequence>
185
+ {`Pagamentos já aprovados e estornos em andamento seguem normalmente pelo ${displayName}.`}
186
+ </Consequence>
187
+ <Consequence>Para reconectar, os passos recomeçam do zero.</Consequence>
188
+ </Stack>
189
+ </DialogContent>
190
+ <DialogActions sx={{ display: 'flex', flexDirection: 'column', gap: '8px', p: '18px 22px 20px' }}>
191
+ {/* Offered FIRST, and only when it is a real alternative. An owner who
192
+ came here to stop taking orders wants the switch, not the removal —
193
+ and reaching for the destructive control is how they got here. */}
194
+ {receiving && props.onPauseInstead ? (
195
+ <Button
196
+ fullWidth
197
+ onClick={props.onPauseInstead}
198
+ disabled={props.busy}
199
+ sx={BTN_SECONDARY_SX}
200
+ data-testid="payments-pause-instead"
201
+ >
202
+ Só pausar o recebimento
203
+ </Button>
204
+ ) : null}
205
+ <Button
206
+ fullWidth
207
+ variant="contained"
208
+ disableElevation
209
+ onClick={props.onConfirm}
210
+ disabled={props.busy}
211
+ data-testid="payments-disconnect-confirm-action"
212
+ sx={{ ...BTN_PRIMARY_SX, background: T.bad, '&:hover': { background: '#a51f1f' } }}
213
+ >
214
+ {props.busy ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : 'Remover conexão'}
215
+ </Button>
216
+ <Button fullWidth onClick={props.onCancel} disabled={props.busy} sx={LINKISH_SX}>
217
+ Cancelar
218
+ </Button>
219
+ </DialogActions>
220
+ </Dialog>
221
+ );
222
+ }
223
+
224
+ /** One consequence of removing the connection, marked by how much it costs. */
225
+ function Consequence({ children, tone }: { children: ReactNode; tone?: 'bad' }) {
226
+ return (
227
+ <Stack component="li" direction="row" gap="9px">
228
+ <Box component="span" aria-hidden sx={{ fontWeight: 800, color: tone ? T.bad : T.ink4 }}>
229
+ {tone ? '✕' : '·'}
230
+ </Box>
231
+ <Typography sx={{ fontSize: '12.5px', color: T.ink2, lineHeight: 1.5 }}>{children}</Typography>
232
+ </Stack>
233
+ );
234
+ }
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { Stack } from '@mui/material';
4
+
5
+ import type { ProviderDescriptor } from '@12-apps/payments-backend';
6
+
7
+ import { CredentialField } from './CredentialFields';
8
+ import { saveLabel } from './credential-rules';
9
+ import { FormActions, ReverifyWarning } from './CredentialFormAlerts';
10
+ import type { CredentialFormState } from './ProviderCredentialForm';
11
+
12
+ /**
13
+ * The live inputs for the step still owed, plus the one button that commits
14
+ * them — and, on a store that has already proved it can receive, the warning
15
+ * that saving will undo that.
16
+ *
17
+ * Its own component so `ProviderForm` stays about WHICH of the two shapes is on
18
+ * screen (the collapsed row, or this) rather than about what each contains.
19
+ */
20
+ export function CredentialFields({
21
+ descriptor,
22
+ form,
23
+ proven,
24
+ }: {
25
+ descriptor: ProviderDescriptor;
26
+ form: CredentialFormState;
27
+ /** A real charge has landed through this connection — see `ReverifyWarning`. */
28
+ proven: boolean;
29
+ }) {
30
+ return (
31
+ <Stack spacing={2}>
32
+ {proven ? <ReverifyWarning /> : null}
33
+ {descriptor.credentialSchema.map((spec) => (
34
+ <CredentialField
35
+ key={spec.key}
36
+ spec={spec}
37
+ state={form.masked[spec.key]}
38
+ value={form.values[spec.key]}
39
+ // The probe's own verdict for THIS credential, keyed by the same
40
+ // field id the adapter checked. Shown at the box rather than in a
41
+ // list below the form, where the owner had to match four sentences
42
+ // to four boxes by eye.
43
+ check={form.probe?.checks?.find((entry) => entry.key === spec.key)}
44
+ onChange={(value) => form.edit(spec.key, value)}
45
+ />
46
+ ))}
47
+ <FormActions
48
+ busy={form.busy}
49
+ label={saveLabel(descriptor, form.complete)}
50
+ disabled={form.nothingEdited || !form.valid}
51
+ onSave={form.requestSave}
52
+ />
53
+ </Stack>
54
+ );
55
+ }
@@ -4,6 +4,8 @@ import { Box, Button, Stack, TextField, Typography } from '@mui/material';
4
4
 
5
5
  import type { CredentialFieldSpec, MaskedFieldState } from '@12-apps/payments-backend';
6
6
 
7
+ import { LINKISH_SX, T } from './panel-tokens';
8
+
7
9
  /**
8
10
  * The schema-driven credential inputs, and the one-line summary a finished
9
11
  * step collapses into.
@@ -67,19 +69,122 @@ interface CredentialFieldProps {
67
69
  state: MaskedFieldState | undefined;
68
70
  value: string | undefined;
69
71
  onChange: (value: string) => void;
72
+ /**
73
+ * What the last probe found about THIS credential, when it found anything.
74
+ *
75
+ * The adapter has always returned per-credential verdicts; the screen showed
76
+ * them as a list under the form, which leaves the owner matching four
77
+ * sentences to four boxes by eye. A verdict about a field belongs at the
78
+ * field: "chave publicável de produção em conexão de teste" is unactionable
79
+ * three boxes away from the box it is about.
80
+ */
81
+ check?: { status: 'PASS' | 'FAIL' | 'UNCHECKED'; message: string };
70
82
  }
71
83
 
72
84
  /** One schema-driven form field. Secrets are write-only: hint, never value. */
73
- export function CredentialField({ spec, state, value, onChange }: CredentialFieldProps) {
85
+ export function CredentialField({ spec, state, value, onChange, check }: CredentialFieldProps) {
74
86
  const presentation = fieldPresentation(spec, state, value);
87
+ // Label ABOVE the box, not floating in it. Four credentials whose names are
88
+ // the only thing distinguishing them (`sk_`, `pk_`, `whsec_`, `acct_`) are
89
+ // read as a column, and a floating label disappears the moment a value is
90
+ // pasted — exactly when the owner is checking they pasted into the right one.
91
+ // `htmlFor`/`id` rather than a bare <label>: lifting the text out of
92
+ // TextField also lifts it out of MUI's own labelling, and an input whose
93
+ // label is merely ABOVE it is unlabelled to a screen reader and unfindable by
94
+ // `getByLabelText`. The association has to be restated by hand.
95
+ const inputId = `payments-credential-${spec.key}`;
75
96
  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
- />
97
+ <Box>
98
+ <Typography
99
+ component="label"
100
+ htmlFor={inputId}
101
+ sx={{ display: 'block', fontSize: '12px', fontWeight: 650, color: T.ink2, mb: '5px' }}
102
+ >
103
+ {spec.label}
104
+ {spec.advanced ? (
105
+ <Box component="span" sx={{ fontWeight: 500, color: T.ink4 }}>
106
+ {' · só para plataformas Connect'}
107
+ </Box>
108
+ ) : null}
109
+ </Typography>
110
+ <TextField
111
+ size="small"
112
+ fullWidth
113
+ id={inputId}
114
+ {...presentation}
115
+ helperText={undefined}
116
+ slotProps={
117
+ spec.mono ? { htmlInput: { sx: MONO_INPUT, spellCheck: false } } : { htmlInput: {} }
118
+ }
119
+ sx={fieldSx(check?.status)}
120
+ onChange={(e) => onChange(e.target.value)}
121
+ />
122
+ <FieldNote
123
+ check={check}
124
+ fallback={presentation.helperText ?? spec.helperText}
125
+ testId={`payments-field-note-${spec.key}`}
126
+ />
127
+ </Box>
128
+ );
129
+ }
130
+
131
+ /**
132
+ * The box itself: 8px, hairline, brand focus ring — and the probe's verdict in
133
+ * its border, so a wrong credential is visible before any sentence is read.
134
+ */
135
+ function fieldSx(status?: 'PASS' | 'FAIL' | 'UNCHECKED') {
136
+ const failed = status === 'FAIL';
137
+ return {
138
+ '& .MuiOutlinedInput-root': {
139
+ borderRadius: '8px',
140
+ fontSize: '13px',
141
+ background: failed ? '#fffbfb' : undefined,
142
+ '& fieldset': {
143
+ borderColor: failed ? T.bad : status === 'PASS' ? T.okLine : T.line,
144
+ },
145
+ '&:hover fieldset': { borderColor: failed ? T.bad : T.ink4 },
146
+ '&.Mui-focused fieldset': { borderColor: T.brand, borderWidth: '2px' },
147
+ },
148
+ '& .MuiOutlinedInput-input': { padding: '10px 12px' },
149
+ } as const;
150
+ }
151
+
152
+ /**
153
+ * How each verdict reads under its box. `UNCHECKED` deliberately carries no
154
+ * mark: a tick would claim it passed and a cross would blame the owner, when
155
+ * the truth is that nothing could be established either way.
156
+ */
157
+ const VERDICT = {
158
+ PASS: { mark: '✓', color: T.ok },
159
+ FAIL: { mark: '✕', color: T.bad },
160
+ UNCHECKED: { mark: '', color: T.ink3 },
161
+ NONE: { mark: '', color: T.ink3 },
162
+ } as const;
163
+
164
+ /** The line under a box: the probe's verdict when there is one, else the hint. */
165
+ function FieldNote({
166
+ check,
167
+ fallback,
168
+ testId,
169
+ }: {
170
+ check?: { status: 'PASS' | 'FAIL' | 'UNCHECKED'; message: string };
171
+ fallback?: string;
172
+ testId: string;
173
+ }) {
174
+ const text = check?.message ?? fallback;
175
+ if (!text) return null;
176
+ const { mark, color } = VERDICT[check?.status ?? 'NONE'];
177
+ return (
178
+ <Stack direction="row" gap="6px" alignItems="flex-start" sx={{ mt: '5px' }} data-testid={testId}>
179
+ {mark ? (
180
+ <Box component="span" aria-hidden sx={{ fontWeight: 800, color }}>
181
+ {mark}
182
+ </Box>
183
+ ) : null}
184
+ <Typography sx={{ fontSize: '11.5px', lineHeight: 1.45, color, fontWeight: mark ? 500 : 400 }}>
185
+ {text}
186
+ </Typography>
187
+ </Stack>
83
188
  );
84
189
  }
85
190
 
@@ -128,29 +233,36 @@ export function DoneRow({
128
233
  testId: string;
129
234
  }) {
130
235
  return (
236
+ // Green, not grey: a finished step reads as an achievement at a glance, and
237
+ // the whole point of collapsing it is that the eye can skip it on the way to
238
+ // the step still owed.
131
239
  <Stack
132
240
  direction="row"
133
- spacing={1.5}
241
+ spacing={1.25}
134
242
  alignItems="center"
135
243
  data-testid={testId}
136
- sx={{ border: 1, borderColor: 'divider', borderRadius: 1, px: 2, py: 1.25 }}
244
+ sx={{
245
+ border: `1px solid ${T.okLine}`,
246
+ background: T.okSoft,
247
+ borderRadius: '9px',
248
+ px: '16px',
249
+ py: '11px',
250
+ mx: '20px',
251
+ mb: '10px',
252
+ fontSize: '13px',
253
+ }}
137
254
  >
138
- <Box aria-hidden sx={{ color: 'success.main', fontWeight: 700 }}>
255
+ <Box aria-hidden sx={{ color: T.ok, fontWeight: 800 }}>
139
256
 
140
257
  </Box>
141
- <Typography variant="body2" color="text.secondary">
258
+ <Typography sx={{ fontSize: '13px', fontWeight: 600, color: T.ink }}>
142
259
  {stripTrailingParenthetical(label)}
143
260
  </Typography>
144
- <Typography variant="body2" sx={mono ? MONO_INPUT : undefined}>
261
+ <Typography sx={{ fontSize: '13px', color: T.ink3, ...(mono ? MONO_INPUT : {}) }}>
145
262
  {value}
146
263
  </Typography>
147
264
  <Box sx={{ flexGrow: 1 }} />
148
- <Button
149
- size="small"
150
- onClick={onEdit}
151
- data-testid={`${testId}-edit`}
152
- sx={{ textTransform: 'none' }}
153
- >
265
+ <Button size="small" onClick={onEdit} data-testid={`${testId}-edit`} sx={LINKISH_SX}>
154
266
  {editLabel}
155
267
  </Button>
156
268
  </Stack>
@@ -5,6 +5,7 @@ import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/ma
5
5
  import type { VerifiedProviderConfig } from '@12-apps/payments-backend';
6
6
 
7
7
  import { ENVIRONMENT_LABELS } from './EnvironmentTabs';
8
+ import { BAR_MSG_SX, BAR_SX, BTN_PRIMARY_SX } from './panel-tokens';
8
9
 
9
10
  /**
10
11
  * The credential step's alerts and its one button.
@@ -113,11 +114,19 @@ const CHECK_MARKS = {
113
114
  * red cross would misattribute — and it tells the owner precisely which part of
114
115
  * a passing result not to lean on.
115
116
  */
116
- export function ProbeChecklist({ probe }: { probe: VerifyProbe }) {
117
- if (!probe.checks?.length) return null;
117
+ export function ProbeChecklist({
118
+ probe,
119
+ only,
120
+ }: {
121
+ probe: VerifyProbe;
122
+ /** Keep only the verdicts this predicate accepts — see `ProviderForm`. */
123
+ only?: (key: string) => boolean;
124
+ }) {
125
+ const shown = (probe.checks ?? []).filter((check) => only?.(check.key) ?? true);
126
+ if (shown.length === 0) return null;
118
127
  return (
119
128
  <Stack spacing={0.5} data-testid="payments-probe-checks">
120
- {probe.checks.map((check) => {
129
+ {shown.map((check) => {
121
130
  const { mark, color, label } = CHECK_MARKS[check.status];
122
131
  return (
123
132
  <Stack
@@ -186,25 +195,38 @@ export function FormActions({
186
195
  disabled: boolean;
187
196
  onSave: () => void;
188
197
  }) {
198
+ // The bar states what pressing it costs, which is the half a verb cannot
199
+ // carry: a complete set is sent to the provider on save, and a partial one is
200
+ // only written down. The owner reads the promise and the button in one line.
201
+ const message =
202
+ busy === 'verify'
203
+ ? 'Testando a conexão…'
204
+ : label.includes('testar')
205
+ ? 'Salvamos e testamos as chaves no provedor antes de seguir.'
206
+ : 'Guardamos o que você já preencheu. Complete os campos para testar.';
207
+
189
208
  return (
190
- <Stack spacing={1} alignItems="flex-start">
209
+ <Box sx={BAR_SX} data-testid="payments-form-bar">
210
+ <Typography sx={BAR_MSG_SX} data-testid={busy === 'verify' ? 'payments-verifying' : undefined}>
211
+ {busy === 'verify' ? (
212
+ <Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
213
+ <CircularProgress size={14} />
214
+ {message}
215
+ </Box>
216
+ ) : (
217
+ message
218
+ )}
219
+ </Typography>
191
220
  <Button
192
221
  variant="contained"
222
+ disableElevation
193
223
  data-testid="payments-save"
194
224
  disabled={busy !== null || disabled}
195
- sx={{ textTransform: 'none' }}
225
+ sx={BTN_PRIMARY_SX}
196
226
  onClick={onSave}
197
227
  >
198
- {busy === 'save' ? <CircularProgress size={18} /> : label}
228
+ {busy === 'save' ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : label}
199
229
  </Button>
200
- {busy === 'verify' ? (
201
- <Stack direction="row" spacing={1} alignItems="center" data-testid="payments-verifying">
202
- <CircularProgress size={14} />
203
- <Typography variant="body2" color="text.secondary">
204
- Testando a conexão…
205
- </Typography>
206
- </Stack>
207
- ) : null}
208
- </Stack>
230
+ </Box>
209
231
  );
210
232
  }