@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.
- package/package.json +2 -2
- package/src/client.ts +31 -1
- package/src/components/ConnectionCard.tsx +234 -0
- package/src/components/CredentialFieldStack.tsx +55 -0
- package/src/components/CredentialFields.tsx +131 -19
- package/src/components/CredentialFormAlerts.tsx +37 -15
- package/src/components/EnvironmentTabs.tsx +72 -23
- package/src/components/OAuthPanel.tsx +166 -0
- package/src/components/PaymentProviderSettings.tsx +23 -3
- package/src/components/ProviderConnection.tsx +119 -77
- package/src/components/ProviderCredentialForm.tsx +40 -56
- package/src/components/ProviderPanel.tsx +54 -111
- package/src/components/ProviderSetupGuide.tsx +174 -54
- package/src/components/ProviderStatusBar.tsx +104 -24
- package/src/components/credential-rules.ts +48 -12
- package/src/components/panel-tokens.ts +166 -0
- package/src/index.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.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.
|
|
20
|
+
"@12-apps/payments-backend": "^3.1.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
|
|
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
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
241
|
+
spacing={1.25}
|
|
134
242
|
alignItems="center"
|
|
135
243
|
data-testid={testId}
|
|
136
|
-
sx={{
|
|
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:
|
|
255
|
+
<Box aria-hidden sx={{ color: T.ok, fontWeight: 800 }}>
|
|
139
256
|
✓
|
|
140
257
|
</Box>
|
|
141
|
-
<Typography
|
|
258
|
+
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: T.ink }}>
|
|
142
259
|
{stripTrailingParenthetical(label)}
|
|
143
260
|
</Typography>
|
|
144
|
-
<Typography
|
|
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({
|
|
117
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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={
|
|
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
|
-
|
|
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
|
}
|