@12-apps/payments-frontend 2.0.0 → 2.1.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.
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Alert, Tab, Tabs } from '@mui/material';
3
+ import { Alert, Box, Tab, Tabs, Typography } from '@mui/material';
4
4
 
5
5
  import type { PaymentEnvironment } from '@12-apps/payments-backend';
6
6
 
7
+ import { T } from './panel-tokens';
8
+
7
9
  /** One environment's human name — the tabs and the probe result share it. */
8
10
  export const ENVIRONMENT_LABELS: Record<PaymentEnvironment, string> = {
9
11
  SANDBOX: 'Sandbox',
@@ -14,7 +16,22 @@ export const ENVIRONMENT_LABELS: Record<PaymentEnvironment, string> = {
14
16
  * Sentence case, because these are place names rather than shouted commands —
15
17
  * MUI upper-cases tab labels by default, which turns "Produção" into signage.
16
18
  */
17
- const TAB_SX = { textTransform: 'none' } as const;
19
+ const TAB_SX = {
20
+ textTransform: 'none',
21
+ minHeight: 0,
22
+ minWidth: 0,
23
+ borderRadius: '6px',
24
+ px: '14px',
25
+ py: '6px',
26
+ fontSize: '12.5px',
27
+ fontWeight: 600,
28
+ color: T.ink3,
29
+ '&.Mui-selected': {
30
+ background: T.bg,
31
+ color: T.ink,
32
+ boxShadow: '0 1px 3px rgba(0,0,0,.09)',
33
+ },
34
+ } as const;
18
35
 
19
36
  /**
20
37
  * The two environments as tabs, not a select: there are exactly two, and which
@@ -30,26 +47,45 @@ export function EnvironmentSelector({
30
47
  environment: PaymentEnvironment;
31
48
  onChange: (next: PaymentEnvironment) => void;
32
49
  }) {
50
+ // A segmented control, not underlined tabs. Two options that CHANGE WHAT
51
+ // EVERY FIELD BELOW MEANS should look like a switch being thrown, and the
52
+ // label above says which switch — "Ambiente" alone reads as a page section.
33
53
  return (
34
- <Tabs
35
- value={environment}
36
- onChange={(_, next: PaymentEnvironment) => onChange(next)}
37
- aria-label="Ambiente"
38
- data-testid="payments-environment-tabs"
39
- >
40
- <Tab
41
- label="Sandbox"
42
- value="SANDBOX"
43
- data-testid="payments-environment-SANDBOX"
44
- sx={TAB_SX}
45
- />
46
- <Tab
47
- label="Produção"
48
- value="PRODUCTION"
49
- data-testid="payments-environment-PRODUCTION"
50
- sx={TAB_SX}
51
- />
52
- </Tabs>
54
+ <Box>
55
+ <Typography
56
+ sx={{ display: 'block', fontSize: '12px', fontWeight: 650, color: T.ink2, mb: '5px' }}
57
+ >
58
+ Ambiente desta conexão
59
+ </Typography>
60
+ <Tabs
61
+ value={environment}
62
+ onChange={(_, next: PaymentEnvironment) => onChange(next)}
63
+ aria-label="Ambiente desta conexão"
64
+ data-testid="payments-environment-tabs"
65
+ slotProps={{ indicator: { sx: { display: 'none' } } }}
66
+ sx={{
67
+ minHeight: 0,
68
+ width: 'fit-content',
69
+ background: T.bg2,
70
+ border: `1px solid ${T.line}`,
71
+ borderRadius: '9px',
72
+ p: '4px',
73
+ }}
74
+ >
75
+ <Tab
76
+ label="Sandbox"
77
+ value="SANDBOX"
78
+ data-testid="payments-environment-SANDBOX"
79
+ sx={TAB_SX}
80
+ />
81
+ <Tab
82
+ label="Produção"
83
+ value="PRODUCTION"
84
+ data-testid="payments-environment-PRODUCTION"
85
+ sx={TAB_SX}
86
+ />
87
+ </Tabs>
88
+ </Box>
53
89
  );
54
90
  }
55
91
 
@@ -72,10 +108,23 @@ export function EnvironmentSelector({
72
108
  export function EnvironmentNotice({
73
109
  environment,
74
110
  active,
111
+ band = true,
75
112
  }: {
76
113
  environment: PaymentEnvironment;
77
114
  /** The environment this store's real checkout uses, when it has one. */
78
115
  active: PaymentEnvironment | null;
116
+ /**
117
+ * Rendered as the card's full-bleed strip (the default), or as an ordinary
118
+ * inset alert.
119
+ *
120
+ * The band geometry — square corners, and `px: 3` matching the card's own
121
+ * padding so the text lines up with the content above and below — only reads
122
+ * correctly when the strip actually spans the card. Stacked INSIDE the manual
123
+ * disclosure, where it is already inset by the accordion, the same values
124
+ * indent the text a second time and square off a box that is visibly not
125
+ * touching either edge.
126
+ */
127
+ band?: boolean;
79
128
  }) {
80
129
  const elsewhere = active !== null && active !== environment ? ENVIRONMENT_LABELS[active] : null;
81
130
  const production = environment === 'PRODUCTION';
@@ -83,9 +132,9 @@ export function EnvironmentNotice({
83
132
  <Alert
84
133
  severity={production ? 'warning' : 'info'}
85
134
  variant="standard"
86
- square
135
+ square={band}
87
136
  data-testid={`payments-environment-notice-${environment}`}
88
- sx={{ borderRadius: 0, py: 0.5, px: 3 }}
137
+ sx={band ? { borderRadius: 0, py: 0.5, px: 3 } : { py: 0.5 }}
89
138
  >
90
139
  <strong>
91
140
  {production ? 'Produção — dinheiro real.' : 'Sandbox — ambiente de teste.'}
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import { ConnectionProbe } from './ConnectionProbe';
7
+ import { LINKISH_SX } from './panel-tokens';
8
+ import { ProviderConnection } from './ProviderConnection';
9
+ import type { ActivePanelProps } from './ProviderPanel';
10
+ import { ProviderCard } from './ProviderPanel';
11
+
12
+ /**
13
+ * The OAuth branch of a provider's screen, and the switch between its two
14
+ * connection paths.
15
+ *
16
+ * Split from `ProviderPanel` — which decides WHICH branch a provider gets —
17
+ * because this one is about what the connect branch looks like once chosen, and
18
+ * because the two together no longer fit the size gate.
19
+ */
20
+ /**
21
+ * The collapsed row that moves the owner between the two connection paths.
22
+ *
23
+ * Both directions get one, and they are the same control: the row on screen
24
+ * always names the path you are NOT on. Only the manual leg carries content —
25
+ * the OAuth leg's "content" is the connect card, which belongs above the
26
+ * walkthrough rather than nested inside a disclosure — so `children` is
27
+ * optional and an empty one renders as a bare, clickable row.
28
+ */
29
+ function PathSwitch({
30
+ label,
31
+ expanded,
32
+ onChange,
33
+ testId,
34
+ children,
35
+ }: {
36
+ label: string;
37
+ expanded: boolean;
38
+ onChange: (expanded: boolean) => void;
39
+ testId: string;
40
+ children?: ReactNode;
41
+ }) {
42
+ // Open, this is not a disclosure any more — it IS the path, and dressing a
43
+ // whole block (its own stepper, environment switch, fields and action bar) in
44
+ // a collapsible chrome adds a frame around a frame. So the content renders
45
+ // bare and only the CLOSED state is a control: one quiet line offering the
46
+ // other way in, which is all a path the owner is not on needs to be.
47
+ if (expanded) {
48
+ return (
49
+ <Box data-testid={testId} data-open="true">
50
+ {children}
51
+ </Box>
52
+ );
53
+ }
54
+ return (
55
+ <Box data-testid={testId} sx={{ px: '20px', pb: '20px' }}>
56
+ <Button onClick={() => onChange(true)} sx={{ ...LINKISH_SX, fontSize: '12.5px' }}>
57
+ {label}
58
+ </Button>
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ /**
64
+ * The OAuth path: a connect button as the happy path, with the credential form
65
+ * kept behind a disclosure.
66
+ *
67
+ * ## One path on screen at a time
68
+ *
69
+ * Opening the manual disclosure is the owner SAYING which path they are on.
70
+ * While the connect card stayed above the open form the screen showed both at
71
+ * once — a card explaining that no key needs copying, directly over four boxes
72
+ * asking for keys, with the grant's own controls in between belonging to
73
+ * neither. Every control on that card acts on the OAuth grant; a store
74
+ * connecting by hand has no grant for them to act on.
75
+ *
76
+ * ## Order inside the card
77
+ *
78
+ * The activation step sits directly under the connect card and ABOVE the
79
+ * disclosure: it is what actually turns the store on, and an owner who just
80
+ * authorized has no reason to open a fallback to find it.
81
+ *
82
+ * The walkthrough renders OUTSIDE the disclosure for the same reason — it used
83
+ * to live inside the credential form, which on this branch is folded into the
84
+ * fallback, so the guide and the stepper answering "where am I" were buried
85
+ * behind a label the connect card says there is no reason to open (FUT-691).
86
+ * Its step LIST depends on the path: under authorization step 1 says to press
87
+ * the connect button above, and with pasted keys that button is not rendered.
88
+ * An adapter shipping a `credentialsPath` variant gets its own steps; one that
89
+ * does not keeps the single guide on both paths, unchanged.
90
+ *
91
+ * That fallback is not decoration — stores connected before Connect existed
92
+ * still hold a pasted token, and a deployment with no registered provider
93
+ * application has no working connect button at all. Hiding the form outright
94
+ * would strand both.
95
+ */
96
+ export function OAuthPanel({
97
+ descriptor,
98
+ config,
99
+ client,
100
+ reload,
101
+ prepareConnect,
102
+ verification,
103
+ statusBar,
104
+ walkthrough,
105
+ form,
106
+ }: ActivePanelProps & {
107
+ statusBar: ReactNode;
108
+ walkthrough: (path: 'oauth' | 'credentials') => ReactNode;
109
+ form: ReactNode;
110
+ }) {
111
+ // Which path the owner is on. The disclosure is no longer just a container —
112
+ // opening it CHOOSES the credentials path, so the panel has to know.
113
+ const [manual, setManual] = useState(false);
114
+ // The connection probe, for a store whose connection is a grant: the form's
115
+ // own probe runs off Salvar, which an OAuth store never presses (FUT-691).
116
+ const probe = (
117
+ <ConnectionProbe descriptor={descriptor} config={config} client={client} reload={reload} />
118
+ );
119
+ if (!prepareConnect) {
120
+ return (
121
+ <ProviderCard header={statusBar}>
122
+ <Alert severity="info">
123
+ Este provedor conecta por autorização, mas o botão de conexão não está disponível nesta
124
+ instalação. Você ainda pode conectar informando as credenciais manualmente.
125
+ </Alert>
126
+ {form}
127
+ {probe}
128
+ {verification}
129
+ </ProviderCard>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <ProviderCard header={statusBar}>
135
+ {manual ? (
136
+ <PathSwitch
137
+ label="Prefiro conectar por autorização"
138
+ expanded={false}
139
+ onChange={() => setManual(false)}
140
+ testId="payments-oauth-fallback"
141
+ />
142
+ ) : (
143
+ <ProviderConnection
144
+ descriptor={descriptor}
145
+ config={config}
146
+ client={client}
147
+ prepareConnect={prepareConnect}
148
+ onChanged={reload}
149
+ />
150
+ )}
151
+ {verification}
152
+ {manual ? null : walkthrough('oauth')}
153
+ {probe}
154
+ {descriptor.credentialSchema.length > 0 ? (
155
+ <PathSwitch
156
+ label="Prefiro informar as credenciais manualmente"
157
+ expanded={manual}
158
+ onChange={setManual}
159
+ testId="payments-manual-fallback"
160
+ >
161
+ {form}
162
+ </PathSwitch>
163
+ ) : null}
164
+ </ProviderCard>
165
+ );
166
+ }
@@ -3,7 +3,7 @@
3
3
  import { Alert, Box, Button, CircularProgress } from '@mui/material';
4
4
  import { useCallback, type ReactNode } from 'react';
5
5
 
6
- import type { MaskedProviderConfig } from '@12-apps/payments-backend';
6
+ import type { MaskedProviderConfig, ProviderSetupGuide } from '@12-apps/payments-backend';
7
7
 
8
8
  import type { PaymentsSettingsClient } from '../client';
9
9
  import { canAttemptCharge } from './connection-state';
@@ -195,6 +195,23 @@ function verificationFor(io: VerificationInputs): ReactNode {
195
195
  });
196
196
  }
197
197
 
198
+ /**
199
+ * The walkthrough for the connection path the panel currently has open.
200
+ *
201
+ * A provider that accepts both a grant and pasted keys ships one guide with a
202
+ * `credentialsPath` variant; everything else ships one guide, and both paths
203
+ * get it. Only the SECTIONS are swapped — the variant mirrors the base's stage
204
+ * count and confirmable index, which is what lets `blocked`/`hidden` above stay
205
+ * computed from the base guide alone.
206
+ */
207
+ function guideForPath(
208
+ guide: ProviderSetupGuide | null,
209
+ path: 'oauth' | 'credentials',
210
+ ): ProviderSetupGuide | null {
211
+ if (!guide || path === 'oauth') return guide;
212
+ return guide.credentialsPath ? { ...guide.credentialsPath } : guide;
213
+ }
214
+
198
215
  interface ProviderScreenProps extends ActivePanelProps {
199
216
  onBack: () => void;
200
217
  }
@@ -279,9 +296,12 @@ export function PaymentProviderSettings({
279
296
  onVerified: () => void reload(),
280
297
  onSetupIncomplete: ack.withdraw,
281
298
  })}
282
- guide={(slots) => (
299
+ guide={({ path, ...slots }) => (
283
300
  <SetupGuideSection
284
- guide={guide}
301
+ // Which walkthrough, per the path the panel has open. Only the
302
+ // SECTIONS differ; `blocked`/`hidden` above stay computed from the
303
+ // base guide, which `credentialsPath` is required to mirror.
304
+ guide={guideForPath(guide, path)}
285
305
  confirmed={ack.confirmed}
286
306
  onConfirm={ack.confirm}
287
307
  onReopen={ack.withdraw}
@@ -2,14 +2,10 @@
2
2
 
3
3
  import {
4
4
  Alert,
5
+ Box,
5
6
  Button,
6
7
  Chip,
7
8
  CircularProgress,
8
- Dialog,
9
- DialogActions,
10
- DialogContent,
11
- DialogContentText,
12
- DialogTitle,
13
9
  Stack,
14
10
  Typography,
15
11
  } from '@mui/material';
@@ -23,7 +19,9 @@ import type {
23
19
  } from '@12-apps/payments-backend';
24
20
 
25
21
  import type { PaymentsSettingsClient } from '../client';
22
+ import { ConnectionFacts, ConnectSteps, DisconnectDialog } from './ConnectionCard';
26
23
  import { expiryProximity, isConnected } from './connection-state';
24
+ import { BAR_MSG_SX, BAR_SX, BTN_PRIMARY_SX, BTN_QUIET_DANGER_SX, T } from './panel-tokens';
27
25
 
28
26
  /**
29
27
  * The `authMode: 'oauth'` half of the settings page: a provider whose
@@ -81,6 +79,47 @@ function ExpiryNote(props: { expiresAt: string }) {
81
79
  );
82
80
  }
83
81
 
82
+ /**
83
+ * A failed connect, in the owner's terms.
84
+ *
85
+ * The one case worth naming is the deployment that has registered no OAuth
86
+ * application for this provider. It surfaced as
87
+ * `{"error":"CredentialsError","message":"No platform OAuth application
88
+ * credentials configured for stripe/SANDBOX"}` in a red box under the connect
89
+ * button — an error class, a slash-joined pair of internal identifiers, and not
90
+ * one word about what to do. It is not a failure the owner caused or can fix,
91
+ * and it is not a dead end either: the credentials path works, and it is right
92
+ * there on the same screen. So it reads as a warning that names the way round.
93
+ *
94
+ * Anything else is passed through. The adapter's own sentence beats a generic
95
+ * one, and inventing copy for a failure we have not seen is how a screen ends
96
+ * up confidently misdescribing an outage.
97
+ */
98
+ function connectFailure(
99
+ message: string,
100
+ displayName: string,
101
+ ): { severity: 'error' | 'warning'; text: string } {
102
+ if (/no platform oauth application credentials/i.test(message)) {
103
+ return {
104
+ severity: 'warning',
105
+ text:
106
+ `A conexão automática com ${displayName} não está disponível nesta instalação — ` +
107
+ 'o aplicativo de autorização não foi cadastrado. Para conectar agora, abra ' +
108
+ '“Prefiro informar as credenciais manualmente” abaixo e cole as suas próprias chaves.',
109
+ };
110
+ }
111
+ return { severity: 'error', text: message };
112
+ }
113
+
114
+ function ConnectError({ message, displayName }: { message: string; displayName: string }) {
115
+ const { severity, text } = connectFailure(message, displayName);
116
+ return (
117
+ <Alert severity={severity} data-testid="payments-connect-failure">
118
+ {text}
119
+ </Alert>
120
+ );
121
+ }
122
+
84
123
  function connectLabel(displayName: string, connected: boolean, busy: string | null) {
85
124
  if (busy === 'connect') return <CircularProgress size={18} />;
86
125
  return connected ? 'Reconectar' : `Conectar com ${displayName}`;
@@ -134,6 +173,8 @@ function ConnectedAccountDetails(props: { account: ConnectedOAuthAccount }) {
134
173
  );
135
174
  }
136
175
 
176
+
177
+
137
178
  /** Header + explanatory copy + any warning banner for the connection. */
138
179
  function ConnectionSummary(props: {
139
180
  displayName: string;
@@ -153,15 +194,11 @@ function ConnectionSummary(props: {
153
194
  once connected, since the provider sealed it into the grant.
154
195
  */}
155
196
  {props.connected ? (
156
- <Stack direction="row" spacing={1} alignItems="center">
157
- <Chip
158
- size="small"
159
- variant="outlined"
160
- data-testid="payments-connected-environment"
161
- label={props.environment === 'PRODUCTION' ? 'Produção' : 'Sandbox (testes)'}
162
- color={props.environment === 'PRODUCTION' ? 'default' : 'warning'}
163
- />
164
- </Stack>
197
+ <ConnectionFacts
198
+ environment={props.environment}
199
+ account={props.connectedAccount}
200
+ displayName={props.displayName}
201
+ />
165
202
  ) : null}
166
203
  {/*
167
204
  The connected sentence names NO platform. It used to open with one
@@ -176,11 +213,12 @@ function ConnectionSummary(props: {
176
213
  grant, not whose logo is on the page. The subject is the connection
177
214
  itself, which is true for every host and needs no new configuration.
178
215
  */}
179
- <Typography variant="body2" color="text.secondary">
216
+ <Typography sx={{ fontSize: '13px', color: T.ink2, lineHeight: 1.6 }}>
180
217
  {props.connected
181
218
  ? 'Sua conta está conectada. As cobranças são criadas em seu nome — nenhuma chave precisa ser copiada.'
182
219
  : `Conecte sua conta ${props.displayName} autorizando o acesso no site do provedor. Nenhuma chave precisa ser copiada.`}
183
220
  </Typography>
221
+ {props.connected ? null : <ConnectSteps displayName={props.displayName} />}
184
222
  {props.connected && props.connectedAccount ? (
185
223
  <ConnectedAccountDetails account={props.connectedAccount} />
186
224
  ) : null}
@@ -194,47 +232,16 @@ function ConnectionSummary(props: {
194
232
  );
195
233
  }
196
234
 
235
+
236
+
197
237
  /**
198
- * Confirmation for Desconectar, which is destructive and irreversible from
199
- * here: it revokes the grant at the provider, so the store stops being able to
200
- * charge immediately and getting back requires the owner to authorize again on
201
- * the provider's site. It also sat one careless click from "Reconectar".
238
+ * The connect card's action bar.
239
+ *
240
+ * Removing the connection is stated QUIETLY beside the step's own button rather
241
+ * than as a second filled control: an owner reaches it deliberately or not at
242
+ * all, and a red button of equal weight on every visit is an invitation to
243
+ * misclick the one action here that cannot be undone from this screen.
202
244
  */
203
- function DisconnectDialog(props: {
204
- open: boolean;
205
- displayName: string;
206
- busy: boolean;
207
- onCancel: () => void;
208
- onConfirm: () => void;
209
- }) {
210
- return (
211
- <Dialog open={props.open} onClose={props.onCancel} data-testid="payments-disconnect-confirm">
212
- <DialogTitle>Desconectar {props.displayName}?</DialogTitle>
213
- <DialogContent>
214
- <DialogContentText>
215
- A autorização será revogada no {props.displayName} e sua loja deixa de conseguir cobrar
216
- imediatamente. Para voltar a receber, será necessário conectar a conta novamente
217
- autorizando o acesso no site do provedor.
218
- </DialogContentText>
219
- </DialogContent>
220
- <DialogActions>
221
- <Button onClick={props.onCancel} disabled={props.busy}>
222
- Cancelar
223
- </Button>
224
- <Button
225
- color="error"
226
- variant="contained"
227
- onClick={props.onConfirm}
228
- disabled={props.busy}
229
- data-testid="payments-disconnect-confirm-action"
230
- >
231
- {props.busy ? <CircularProgress size={18} /> : 'Desconectar'}
232
- </Button>
233
- </DialogActions>
234
- </Dialog>
235
- );
236
- }
237
-
238
245
  function ConnectionActions(props: {
239
246
  displayName: string;
240
247
  connected: boolean;
@@ -244,16 +251,32 @@ function ConnectionActions(props: {
244
251
  }) {
245
252
  const { displayName, connected, busy, onConnect, onDisconnect } = props;
246
253
  return (
247
- <Stack direction="row" spacing={1}>
248
- <Button variant="contained" disabled={busy !== null} onClick={onConnect}>
249
- {connectLabel(displayName, connected, busy)}
250
- </Button>
254
+ <Box sx={BAR_SX} data-testid="payments-connection-bar">
255
+ <Typography sx={BAR_MSG_SX}>
256
+ {connected
257
+ ? `Conta ${displayName} conectada. Revogue quando quiser, aqui ou no painel do provedor.`
258
+ : 'Você sai para o provedor e volta para cá — leva menos de um minuto.'}
259
+ </Typography>
251
260
  {connected ? (
252
- <Button variant="outlined" color="error" disabled={busy !== null} onClick={onDisconnect}>
253
- {busy === 'disconnect' ? <CircularProgress size={18} /> : 'Desconectar'}
261
+ <Button
262
+ disabled={busy !== null}
263
+ onClick={onDisconnect}
264
+ sx={BTN_QUIET_DANGER_SX}
265
+ data-testid="payments-disconnect"
266
+ >
267
+ {busy === 'disconnect' ? <CircularProgress size={18} /> : 'Remover conexão'}
254
268
  </Button>
255
269
  ) : null}
256
- </Stack>
270
+ <Button
271
+ variant="contained"
272
+ disableElevation
273
+ disabled={busy !== null}
274
+ onClick={onConnect}
275
+ sx={BTN_PRIMARY_SX}
276
+ >
277
+ {connectLabel(displayName, connected, busy)}
278
+ </Button>
279
+ </Box>
257
280
  );
258
281
  }
259
282
 
@@ -278,17 +301,37 @@ function useConnectionAction(onChanged: () => void) {
278
301
  return { busy, error, run };
279
302
  }
280
303
 
304
+ /**
305
+ * The connection as this card reads it, with every default applied once.
306
+ *
307
+ * `environment` follows whatever the provider is already configured for and
308
+ * falls back to SANDBOX, so a live grant is never the accident — changing it is
309
+ * an ADVANCED action that lives with the manual credentials, not on the
310
+ * one-button connect card.
311
+ */
312
+ function readConnection(config: MaskedProviderConfig | null) {
313
+ return {
314
+ environment: (config?.environment ?? 'SANDBOX') as PaymentEnvironment,
315
+ status: config?.status ?? 'UNVERIFIED',
316
+ connected: isConnected(config),
317
+ expiresAt: config?.expiresAt ?? null,
318
+ account: config?.connectedAccount ?? null,
319
+ receiving: config?.enabled === true,
320
+ };
321
+ }
322
+
281
323
  export function ProviderConnection(props: ProviderConnectionProps) {
282
324
  const { descriptor, config, client, prepareConnect, onChanged } = props;
283
325
  const { busy, error, run } = useConnectionAction(onChanged);
284
326
  const [confirmingDisconnect, setConfirmingDisconnect] = useState(false);
285
- // Which account the owner is connecting. Follows whatever this provider is
286
- // already configured for, defaulting to SANDBOX so a live grant is never the
287
- // accident. Changing it is an ADVANCED action and lives with the manual
288
- // credentials, not on the one-button connect card.
289
- const environment: PaymentEnvironment = config?.environment ?? 'SANDBOX';
290
- const status = config?.status ?? 'UNVERIFIED';
291
- const connected = isConnected(config);
327
+ const { environment, status, connected, expiresAt, account, receiving } = readConnection(config);
328
+
329
+ /** Run a dialog action and shut the dialog once it has actually landed. */
330
+ const close = (kind: string, action: () => Promise<unknown>) =>
331
+ void run(kind, async () => {
332
+ await action();
333
+ setConfirmingDisconnect(false);
334
+ });
292
335
 
293
336
  const connect = () =>
294
337
  void run('connect', async () => {
@@ -307,12 +350,12 @@ export function ProviderConnection(props: ProviderConnectionProps) {
307
350
  displayName={descriptor.displayName}
308
351
  status={status}
309
352
  connected={connected}
310
- expiresAt={config?.expiresAt ?? null}
353
+ expiresAt={expiresAt}
311
354
  environment={environment}
312
- connectedAccount={config?.connectedAccount ?? null}
355
+ connectedAccount={account}
313
356
  />
314
357
 
315
- {error ? <Alert severity="error">{error}</Alert> : null}
358
+ {error ? <ConnectError message={error} displayName={descriptor.displayName} /> : null}
316
359
 
317
360
  <ConnectionActions
318
361
  displayName={descriptor.displayName}
@@ -326,13 +369,12 @@ export function ProviderConnection(props: ProviderConnectionProps) {
326
369
  open={confirmingDisconnect}
327
370
  displayName={descriptor.displayName}
328
371
  busy={busy === 'disconnect'}
372
+ // Live on this provider: removing it stops checkout, and pausing is a
373
+ // real alternative rather than a lesser version of the same thing.
374
+ receiving={receiving}
375
+ onPauseInstead={() => close('pause', () => client.setEnabled(descriptor.name, false))}
329
376
  onCancel={() => setConfirmingDisconnect(false)}
330
- onConfirm={() =>
331
- void run('disconnect', async () => {
332
- await client.disconnectOAuth(descriptor.name);
333
- setConfirmingDisconnect(false);
334
- })
335
- }
377
+ onConfirm={() => close('disconnect', () => client.disconnectOAuth(descriptor.name))}
336
378
  />
337
379
  </Stack>
338
380
  );