@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.
@@ -6,11 +6,14 @@ import {
6
6
  Button,
7
7
  IconButton,
8
8
  Link,
9
- Paper,
10
9
  Stack,
11
10
  Step,
11
+ StepConnector,
12
+ stepConnectorClasses,
12
13
  StepLabel,
13
14
  Stepper,
15
+ styled,
16
+ type StepIconProps,
14
17
  TextField,
15
18
  Typography,
16
19
  } from '@mui/material';
@@ -18,6 +21,15 @@ import { useState, type ReactNode } from 'react';
18
21
 
19
22
  import type { ProviderSetupGuide as Guide, SetupSection, SetupStep } from '@12-apps/payments-backend';
20
23
 
24
+ import {
25
+ BAR_MSG_SX,
26
+ BAR_SX,
27
+ BTN_PRIMARY_SX,
28
+ BTN_SECONDARY_SX,
29
+ PANEL_SX,
30
+ T,
31
+ } from './panel-tokens';
32
+
21
33
  import { richText } from './rich-text';
22
34
 
23
35
  /**
@@ -122,6 +134,39 @@ const BUTTON_SX = { textTransform: 'none' } as const;
122
134
 
123
135
  type StepActions = ProviderSetupGuideProps['actions'];
124
136
 
137
+ /** A step's sentence, with the provider's own reference inline after it. */
138
+ function StepText({ text, link }: { text?: string; link?: SetupStep['link'] }) {
139
+ if (!text) return null;
140
+ return (
141
+ <Typography sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.5 }}>
142
+ {richText(text)}{' '}
143
+ {link ? (
144
+ <Link href={link.url} target="_blank" rel="noreferrer">
145
+ {link.label}
146
+ </Link>
147
+ ) : null}
148
+ </Typography>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * The panel's action bar: what this step is asking, and the button that answers.
154
+ *
155
+ * The sentence is not decoration. This is the one step no API can report, so
156
+ * the owner is being asked to vouch for work done somewhere else — and a bare
157
+ * button gives them nothing to weigh that against.
158
+ */
159
+ function ConfirmBar({ action }: { action: { label: string; run: () => void } }) {
160
+ return (
161
+ <Box sx={BAR_SX} data-testid="payments-setup-confirm-bar">
162
+ <Typography sx={BAR_MSG_SX}>Confirme quando terminar do lado do provedor.</Typography>
163
+ <Button variant="contained" disableElevation sx={BTN_PRIMARY_SX} onClick={() => action.run()}>
164
+ {action.label}
165
+ </Button>
166
+ </Box>
167
+ );
168
+ }
169
+
125
170
  interface StepRowProps {
126
171
  step: SetupStep;
127
172
  actions: StepActions;
@@ -153,37 +198,21 @@ function WarningRow({ text }: { text: string }) {
153
198
  function StepRow({ step, actions }: StepRowProps) {
154
199
  const action = step.action ? actions?.[step.action] : undefined;
155
200
  if (step.tone === 'warning') return <WarningRow text={step.text ?? ''} />;
201
+ // An action-only step IS the panel's action bar — see `SectionCard`.
202
+ if (action && !step.text) return <ConfirmBar action={action} />;
203
+ // A bordered row with the work on the left and the way to do it on the
204
+ // right, so a step reads as a thing to tick off rather than as a paragraph.
205
+ // The instructions on this screen are a CHECKLIST — each one is a piece of
206
+ // work the owner does somewhere else and comes back from.
156
207
  return (
157
- <Stack spacing={1}>
158
- {step.text ? (
159
- <Typography variant="body2">
160
- {richText(step.text)}{' '}
161
- {step.link ? (
162
- <Link href={step.link.url} target="_blank" rel="noreferrer">
163
- {step.link.label}
164
- </Link>
165
- ) : null}
166
- </Typography>
167
- ) : null}
168
- {step.button ? (
169
- <Box>
170
- <Button
171
- variant="outlined"
172
- size="small"
173
- href={step.button.url}
174
- target="_blank"
175
- rel="noreferrer"
176
- sx={BUTTON_SX}
177
- // The mark is the promise: this leaves the store and opens the
178
- // provider's site. A button that reads the same as the in-page ones
179
- // and then navigates away is a small betrayal, and here it lands on
180
- // a screen that can CHANGE the tag.
181
- endIcon={<Box component="span" aria-hidden sx={{ fontSize: '0.9em' }}>↗</Box>}
182
- >
183
- {step.button.label}
184
- </Button>
185
- </Box>
186
- ) : null}
208
+ <Stack
209
+ direction="row"
210
+ gap="12px"
211
+ alignItems="flex-start"
212
+ sx={{ border: `1px solid ${T.line}`, borderRadius: '9px', px: '14px', py: '12px' }}
213
+ >
214
+ <Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
215
+ <StepText text={step.text} link={step.link} />
187
216
  {action ? (
188
217
  <Box>
189
218
  <Button variant="contained" size="small" sx={BUTTON_SX} onClick={() => void action.run()}>
@@ -191,17 +220,88 @@ function StepRow({ step, actions }: StepRowProps) {
191
220
  </Button>
192
221
  </Box>
193
222
  ) : null}
194
- {step.copy ? (
195
- <CopyField
196
- label={step.copy.label}
197
- text={step.copy.text}
198
- collapsible={step.copy.collapsible}
199
- />
223
+ {step.copy ? (
224
+ <CopyField
225
+ label={step.copy.label}
226
+ text={step.copy.text}
227
+ collapsible={step.copy.collapsible}
228
+ />
229
+ ) : null}
230
+ </Stack>
231
+ {step.button ? (
232
+ <Button
233
+ size="small"
234
+ href={step.button.url}
235
+ target="_blank"
236
+ rel="noreferrer"
237
+ sx={{ ...BTN_SECONDARY_SX, px: '12px', py: '7px', fontSize: '12px', flexShrink: 0 }}
238
+ // The mark is the promise: this leaves the store and opens the
239
+ // provider's site. A button that reads the same as the in-page ones
240
+ // and then navigates away is a small betrayal, and here it lands on
241
+ // a screen that can CHANGE where the money goes.
242
+ endIcon={
243
+ <Box component="span" aria-hidden sx={{ fontSize: '0.9em' }}>
244
+
245
+ </Box>
246
+ }
247
+ >
248
+ {step.button.label}
249
+ </Button>
200
250
  ) : null}
201
251
  </Stack>
202
252
  );
203
253
  }
204
254
 
255
+ /**
256
+ * The numbered dot: 24px, filled once the store is ON or PAST the step.
257
+ *
258
+ * MUI's own icon is a 24px circle with the number inside and the same fill for
259
+ * active and completed, which is nearly the prototype — the differences are the
260
+ * exact greys and the ✓ on a finished step, and on a screen whose whole job is
261
+ * "where am I" those are the two things that carry the answer.
262
+ */
263
+ function StageIcon({ active, completed, icon }: StepIconProps) {
264
+ const filled = active || completed;
265
+ return (
266
+ <Box
267
+ sx={{
268
+ width: 24,
269
+ height: 24,
270
+ borderRadius: '50%',
271
+ background: filled ? T.brand : '#d9dbe1',
272
+ color: '#fff',
273
+ fontSize: '12px',
274
+ fontWeight: 700,
275
+ display: 'grid',
276
+ placeItems: 'center',
277
+ }}
278
+ >
279
+ {completed ? '✓' : icon}
280
+ </Box>
281
+ );
282
+ }
283
+
284
+ /** A 2px rule that turns brand-coloured behind the steps already passed. */
285
+ const StageConnector = styled(StepConnector)({
286
+ top: 11,
287
+ [`& .${stepConnectorClasses.line}`]: { borderTopWidth: 2, borderColor: T.line },
288
+ [`&.${stepConnectorClasses.active} .${stepConnectorClasses.line}`]: { borderColor: T.brandLine },
289
+ [`&.${stepConnectorClasses.completed} .${stepConnectorClasses.line}`]: {
290
+ borderColor: T.brandLine,
291
+ },
292
+ });
293
+
294
+ const STAGE_LABEL_SX = {
295
+ '& .MuiStepLabel-label': {
296
+ fontSize: '12px',
297
+ color: T.ink3,
298
+ lineHeight: 1.25,
299
+ mt: '7px !important',
300
+ '&.Mui-active': { color: T.ink, fontWeight: 650 },
301
+ '&.Mui-completed': { color: T.ink3, fontWeight: 400 },
302
+ },
303
+ } as const;
304
+
205
305
  export function ProviderSetupGuide({
206
306
  guide,
207
307
  activeStage = 0,
@@ -210,11 +310,18 @@ export function ProviderSetupGuide({
210
310
  sectionFooter,
211
311
  }: ProviderSetupGuideProps) {
212
312
  return (
213
- <Stack spacing={3} data-testid="payments-setup-guide">
214
- <Stepper activeStep={activeStage} alternativeLabel>
215
- {guide.stages.map((stage) => (
216
- <Step key={stage.id}>
217
- <StepLabel>{stage.label}</StepLabel>
313
+ <Stack spacing={0} data-testid="payments-setup-guide">
314
+ <Stepper
315
+ activeStep={activeStage}
316
+ alternativeLabel
317
+ connector={<StageConnector />}
318
+ sx={{ px: '20px', pt: '6px', pb: '18px' }}
319
+ >
320
+ {guide.stages.map((stage, index) => (
321
+ <Step key={stage.id} completed={index < activeStage}>
322
+ <StepLabel slots={{ stepIcon: StageIcon }} sx={STAGE_LABEL_SX}>
323
+ {stage.label}
324
+ </StepLabel>
218
325
  </Step>
219
326
  ))}
220
327
  </Stepper>
@@ -235,29 +342,42 @@ function SectionCard({
235
342
  actions: StepActions;
236
343
  footer?: ReactNode;
237
344
  }) {
345
+ // The step whose completion only the OWNER can report is not a row among the
346
+ // instructions — it is what this panel is FOR. It moves to the action bar, so
347
+ // the control the owner is working toward is the last thing in the block and
348
+ // stays on screen while they read the steps above it.
349
+ const asks = section.steps.filter((step) => step.action !== undefined);
350
+ const reads = section.steps.filter((step) => step.action === undefined);
351
+
238
352
  return (
239
- <Paper
240
- variant="outlined"
241
- sx={{ p: 2 }}
353
+ <Box
354
+ sx={PANEL_SX}
242
355
  // Which section is showing is now a FACT about the store's progress, not
243
356
  // a constant, so it needs to be assertable by id rather than by matching
244
357
  // the prose inside it.
245
358
  data-testid={`payments-setup-section-${section.id}`}
246
359
  >
247
- <Stack spacing={2}>
248
- <Typography variant="subtitle1" fontWeight="bold">
360
+ <Box sx={{ px: '18px', pt: '15px' }}>
361
+ <Typography sx={{ fontSize: '14.5px', fontWeight: 700, color: T.ink }}>
249
362
  {section.title}
250
363
  </Typography>
251
364
  {section.intro ? (
252
- <Typography variant="body2" color="text.secondary">
365
+ <Typography sx={{ fontSize: '12.5px', color: T.ink3, mt: '5px', lineHeight: 1.5 }}>
253
366
  {richText(section.intro)}
254
367
  </Typography>
255
368
  ) : null}
256
- {section.steps.map((step, index) => (
257
- <StepRow key={index} step={step} actions={actions} />
258
- ))}
259
- {footer}
260
- </Stack>
261
- </Paper>
369
+ </Box>
370
+ <Box sx={{ px: '18px', pt: '14px', pb: '18px' }}>
371
+ <Stack spacing={1.5}>
372
+ {reads.map((step, index) => (
373
+ <StepRow key={index} step={step} actions={actions} />
374
+ ))}
375
+ {footer}
376
+ </Stack>
377
+ </Box>
378
+ {asks.map((step, index) => (
379
+ <StepRow key={`ask-${index}`} step={step} actions={actions} />
380
+ ))}
381
+ </Box>
262
382
  );
263
383
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Box, Chip, FormControlLabel, Stack, Switch, Tooltip, Typography } from '@mui/material';
3
+ import { Box, Stack, Switch, Tooltip, Typography } from '@mui/material';
4
4
 
5
5
  import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
6
6
 
7
7
  import { isConnected } from './connection-state';
8
+ import { T } from './panel-tokens';
8
9
 
9
10
  /**
10
11
  * The provider's headline: what state it is in, and the switch that decides
@@ -124,6 +125,36 @@ function toggleGate(
124
125
  };
125
126
  }
126
127
 
128
+ /**
129
+ * The three sentences the header can be saying, chosen once.
130
+ *
131
+ * Proven-but-off is its OWN state. "Não está recebendo" is true of a store that
132
+ * never finished setup AND of one that finished and paused, and those are
133
+ * opposite situations: the first is a step outstanding, the second a decision
134
+ * the owner made and can undo in one click.
135
+ */
136
+ function headline(io: { enabled: boolean; paused: boolean; lockedOff: boolean; hint: string }) {
137
+ if (io.enabled) {
138
+ return {
139
+ state: 'Recebendo vendas',
140
+ sub: 'Sua loja está recebendo por este provedor.',
141
+ tone: T.ok,
142
+ };
143
+ }
144
+ if (io.paused) {
145
+ return {
146
+ state: 'Pausado',
147
+ sub: 'Conexão pronta e pausada por você — nenhum pedido novo é cobrado aqui.',
148
+ tone: T.warn,
149
+ };
150
+ }
151
+ return {
152
+ state: 'Ainda não está recebendo',
153
+ sub: io.lockedOff ? io.hint : 'Tudo pronto — ligue a chave para começar a receber.',
154
+ tone: T.ink3,
155
+ };
156
+ }
157
+
127
158
  /**
128
159
  * Status chip + the "recebendo vendas" switch, hoisted OUT of the credential
129
160
  * form.
@@ -142,6 +173,52 @@ function toggleGate(
142
173
  * you hover it. The reason is printed underneath instead of hidden in a
143
174
  * tooltip, since the owner who most needs it is the one who cannot click.
144
175
  */
176
+ /**
177
+ * The provider's name and its status chip, side by side.
178
+ *
179
+ * The name is a REAL heading, not a div sized to look like one: it is what this
180
+ * screen is about, and it is how a screen reader — and the harness — identifies
181
+ * the page it landed on. The restyle set the size by hand and took the element
182
+ * with it, which no unit test could see and the harness caught at once.
183
+ */
184
+ function ProviderName({
185
+ displayName,
186
+ label,
187
+ proven,
188
+ }: {
189
+ displayName: string;
190
+ label: string;
191
+ proven: boolean;
192
+ }) {
193
+ return (
194
+ <Stack direction="row" alignItems="center" gap="9px">
195
+ <Typography
196
+ component="h2"
197
+ sx={{ m: 0, fontSize: '19px', fontWeight: 700, letterSpacing: '-.01em', color: T.ink }}
198
+ >
199
+ {displayName}
200
+ </Typography>
201
+ <Box
202
+ component="span"
203
+ data-testid="payments-status"
204
+ sx={{
205
+ fontSize: '10px',
206
+ fontWeight: 800,
207
+ letterSpacing: '.06em',
208
+ textTransform: 'uppercase',
209
+ borderRadius: '5px',
210
+ px: '7px',
211
+ py: '3px',
212
+ background: proven ? T.okSoft : '#f0f1f4',
213
+ color: proven ? T.ok : T.ink3,
214
+ }}
215
+ >
216
+ {label}
217
+ </Box>
218
+ </Stack>
219
+ );
220
+ }
221
+
145
222
  export function ProviderStatusBar({
146
223
  descriptor,
147
224
  config,
@@ -157,37 +234,40 @@ export function ProviderStatusBar({
157
234
  const { lockedOff, hint } = toggleGate(descriptor, config, enabled);
158
235
  const badge = statusBadge(config, descriptor);
159
236
 
237
+ const proven = Boolean(config?.chargeVerifiedAt);
238
+ const paused = proven && !enabled;
239
+ const { state, sub, tone } = headline({ enabled, paused, lockedOff, hint });
240
+
160
241
  return (
161
- <Stack spacing={0.5}>
162
- <Stack direction="row" spacing={1} alignItems="center">
163
- <Typography variant="h6">{descriptor.displayName}</Typography>
164
- <Chip size="small" data-testid="payments-status" label={badge.label} color={badge.color} />
165
- {/* The switch belongs at the far edge: it is the one control here that
166
- changes what buyers experience, and crowding it against the status
167
- chip made the two read as one compound widget. */}
168
- <Box sx={{ flexGrow: 1 }} />
242
+ <Stack direction="row" alignItems="flex-start" gap="14px" flexWrap="wrap">
243
+ <Box>
244
+ <ProviderName displayName={descriptor.displayName} label={badge.label} proven={proven} />
245
+ <Typography
246
+ sx={{ fontSize: '12.5px', color: T.ink3, mt: '5px', maxWidth: '52ch', lineHeight: 1.5 }}
247
+ data-testid="payments-enable-hint"
248
+ >
249
+ {sub}
250
+ </Typography>
251
+ </Box>
252
+ {/* The switch belongs at the far edge: it is the one control here that
253
+ changes what buyers experience, and crowding it against the status
254
+ chip made the two read as one compound widget. */}
255
+ <Stack direction="row" alignItems="center" gap="10px" sx={{ ml: 'auto' }}>
169
256
  <Tooltip title={hint}>
170
257
  {/* A disabled control fires no events, so the tooltip needs a live wrapper. */}
171
258
  <span>
172
- <FormControlLabel
173
- control={
174
- <Switch
175
- data-testid="payments-enabled-toggle"
176
- checked={enabled}
177
- disabled={busy || lockedOff}
178
- onChange={(_, next) => onToggle(next)}
179
- />
180
- }
181
- label={enabled ? 'Recebendo vendas' : 'Não está recebendo'}
259
+ <Switch
260
+ data-testid="payments-enabled-toggle"
261
+ checked={enabled}
262
+ disabled={busy || lockedOff}
263
+ onChange={(_, next) => onToggle(next)}
182
264
  />
183
265
  </span>
184
266
  </Tooltip>
185
- </Stack>
186
- {lockedOff ? (
187
- <Typography variant="caption" color="text.secondary" data-testid="payments-enable-hint">
188
- {hint}
267
+ <Typography sx={{ fontSize: '12.5px', fontWeight: tone === T.ink3 ? 400 : 600, color: tone }}>
268
+ {state}
189
269
  </Typography>
190
- ) : null}
270
+ </Stack>
191
271
  </Stack>
192
272
  );
193
273
  }
@@ -31,19 +31,6 @@ import type { CheckoutOrder } from "./types";
31
31
  */
32
32
  export const HOSTED_ORDER_STORAGE_KEY = "payments.checkout.hostedOrder";
33
33
 
34
- /**
35
- * The key before the rename, READ ONLY.
36
- *
37
- * A buyer who left for the provider's page on the old bundle comes back to the
38
- * new one with their order parked under the old name. Without this they land on
39
- * the plain return screen — the order still settles, because the webhook does
40
- * that and never depended on any of this, but the confirmation they were
41
- * promised is missing for a reason they could not possibly understand.
42
- *
43
- * Delete once no session can still be mid-redirect across that deploy.
44
- */
45
- const LEGACY_KEY = "futurepay.checkout.hostedOrder";
46
-
47
34
  /**
48
35
  * What a hosted provider appends to the return URL. InfinitePay sends the
49
36
  * first three; Stripe's redirect-based 3-D Secure appends `payment_intent`
@@ -87,25 +74,21 @@ export function rememberHostedOrder(order: CheckoutOrder): void {
87
74
  * resumed view belongs to exactly one return.
88
75
  */
89
76
  /**
90
- * The raw parked payload under either key, cleared as it is read.
77
+ * The raw parked payload, cleared as it is read.
91
78
  *
92
79
  * Split out from {@link takeHostedOrder} so the storage handling and the
93
- * parsing stay separately readable — reading two keys and clearing both put the
94
- * combined function over the complexity gate, and the two halves fail for
95
- * unrelated reasons anyway (storage disabled vs. a value that is not an order).
80
+ * parsing stay separately readable — the two halves fail for unrelated reasons
81
+ * anyway (storage disabled vs. a value that is not an order).
96
82
  *
97
- * BOTH keys are cleared whichever one answered: this is read-and-clear, and a
98
- * legacy entry left behind would let a later return trip resume an order that
99
- * was already consumed.
83
+ * (This once also read a pre-rename legacy key, kept only for sessions that
84
+ * were mid-redirect across the deploy that renamed the storage key. A hosted
85
+ * round trip lasts minutes, so that window is long closed and the shim is
86
+ * gone.)
100
87
  */
101
88
  function takeParkedPayload(): string | null {
102
89
  try {
103
- const raw =
104
- window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ??
105
- window.sessionStorage?.getItem(LEGACY_KEY) ??
106
- null;
90
+ const raw = window.sessionStorage?.getItem(HOSTED_ORDER_STORAGE_KEY) ?? null;
107
91
  window.sessionStorage?.removeItem(HOSTED_ORDER_STORAGE_KEY);
108
- window.sessionStorage?.removeItem(LEGACY_KEY);
109
92
  return raw;
110
93
  } catch {
111
94
  // Storage disabled or unavailable — the same "no parked order" as an empty
@@ -18,21 +18,36 @@ import type { PendingSave } from './ConfirmCredentialSave';
18
18
  */
19
19
 
20
20
  /**
21
- * Is there actually something stored to probe?
21
+ * Is there enough on record to make the probe WORTH RUNNING?
22
22
  *
23
- * Read from the SERVER's answer to the save rather than from the form: blank
24
- * fields are preserved rather than cleared, so what the browser just typed does
25
- * not describe what is now on record.
23
+ * A required-field test cannot answer this, and Stripe is why: every field in its
24
+ * schema is `required: false`, because under authorization the access token
25
+ * fulfils the secret key and nothing needs typing at all. On the credentials
26
+ * path that makes the required-field test vacuously true, so a save with one
27
+ * key pasted and three boxes empty went straight to the probe and came back
28
+ * "Credenciais recusadas pela Stripe." — a rejection reported for a request
29
+ * that was never worth making, blaming the owner mid-way through typing.
30
+ *
31
+ * So completeness is measured over the schema MINUS its `advanced` fields.
32
+ * Not the whole schema: that asked for Stripe's `connectedAccountId`, which
33
+ * ordinary stores must leave empty, and owners filled it with their own account
34
+ * id to satisfy the button — producing exactly the refusal this was meant to
35
+ * prevent. A partial save is still a perfectly good save (blank fields preserve
36
+ * what is stored); it simply is not yet a connection worth testing.
26
37
  */
27
- export function allRequiredStored(
38
+ export function credentialsComplete(
28
39
  descriptor: ProviderDescriptor,
29
- config: MaskedProviderConfig,
40
+ config: MaskedProviderConfig | null,
30
41
  environment: PaymentEnvironment,
42
+ values: Record<string, string>,
31
43
  ): boolean {
32
- const stored = config.environments[environment] ?? {};
33
- return descriptor.credentialSchema
34
- .filter((spec) => spec.required)
35
- .every((spec) => stored[spec.key]?.configured === true);
44
+ const stored = config?.environments[environment] ?? {};
45
+ return descriptor.credentialSchema.every((spec) => {
46
+ if (spec.advanced) return true;
47
+ const typed = values[spec.key];
48
+ if (typed !== undefined && typed.trim() !== '') return true;
49
+ return stored[spec.key]?.configured === true;
50
+ });
36
51
  }
37
52
 
38
53
  /**
@@ -55,10 +70,31 @@ export function fieldsWellFormed(
55
70
  });
56
71
  }
57
72
 
58
- export function saveLabel(descriptor: ProviderDescriptor): string {
73
+ /**
74
+ * What the one button says it will do — and it does BOTH halves.
75
+ *
76
+ * Saving IS testing (see `FormActions`): the write is followed straight away by
77
+ * the probe, which is what reaches the provider with the pasted keys and, on a
78
+ * pass, is the only thing that makes the activation charge appear. A button
79
+ * reading "Salvar" claims half of that. On the credentials path the owner has
80
+ * no other control, so they were left looking for the one that sends the keys
81
+ * to the provider — there isn't one, because this is it.
82
+ *
83
+ * …but only once there is a connection to test. Half a credential set cannot
84
+ * be, so a partly-filled form says plain "Salvar" and the save skips the probe:
85
+ * promising a test that is then reported as "Credenciais recusadas" is worse
86
+ * than promising nothing, because it reads as a verdict on what was typed.
87
+ *
88
+ * A provider whose whole connection is ONE field keeps naming that field
89
+ * instead. The label then says what is about to be committed rather than merely
90
+ * that something is, and on a step whose entire content is that single box that
91
+ * is the more useful of the two truths. Naming one of Stripe's four would be a
92
+ * lie about what the button writes, which is why only the fallback changes.
93
+ */
94
+ export function saveLabel(descriptor: ProviderDescriptor, complete: boolean): string {
59
95
  const required = descriptor.credentialSchema.filter((field) => field.required);
60
96
  const only = required.length === 1 ? required[0] : undefined;
61
- if (!only) return 'Salvar';
97
+ if (!only) return complete ? 'Salvar e testar conexão' : 'Salvar';
62
98
  return `Salvar ${only.label.replace(/\s*\([^)]*\)\s*$/, '')}`;
63
99
  }
64
100