@12-apps/payments-frontend 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +34 -0
- package/package.json +63 -0
- package/src/__tests__/checkout-confirmation.test.tsx +177 -0
- package/src/__tests__/connection-state.test.tsx +84 -0
- package/src/__tests__/context.test.tsx +88 -0
- package/src/__tests__/controlled-provider.test.tsx +116 -0
- package/src/__tests__/credential-confirm.test.tsx +193 -0
- package/src/__tests__/initial-provider.test.tsx +81 -0
- package/src/__tests__/provider-priority-list.test.tsx +159 -0
- package/src/__tests__/provider-status-bar.test.tsx +152 -0
- package/src/__tests__/verification-slot.test.tsx +125 -0
- package/src/client.ts +200 -0
- package/src/components/CheckoutFlow.tsx +169 -0
- package/src/components/CheckoutPayment.tsx +379 -0
- package/src/components/ConfirmCredentialSave.tsx +106 -0
- package/src/components/CredentialFields.tsx +158 -0
- package/src/components/CredentialFormAlerts.tsx +144 -0
- package/src/components/EnvironmentTabs.tsx +109 -0
- package/src/components/PaymentProviderSettings.tsx +267 -0
- package/src/components/ProviderConnection.tsx +251 -0
- package/src/components/ProviderCredentialForm.tsx +387 -0
- package/src/components/ProviderList.tsx +126 -0
- package/src/components/ProviderPanel.tsx +300 -0
- package/src/components/ProviderPriorityList.tsx +293 -0
- package/src/components/ProviderSetupGuide.tsx +263 -0
- package/src/components/ProviderStatusBar.tsx +192 -0
- package/src/components/SetupGuideSection.tsx +190 -0
- package/src/components/checkout-ack.ts +106 -0
- package/src/components/connection-state.ts +66 -0
- package/src/components/credential-rules.ts +120 -0
- package/src/components/rich-text.tsx +27 -0
- package/src/components/settings-state.ts +211 -0
- package/src/context.tsx +144 -0
- package/src/index.ts +69 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Chip, Typography } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import type { MaskedProviderConfig, MerchantSettingsView, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
8
|
+
import { connectionBadge } from './connection-state';
|
|
9
|
+
import { ProviderPriorityList, chainOf } from './ProviderPriorityList';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provider picker — one card per provider, click to open it.
|
|
13
|
+
*
|
|
14
|
+
* Replaces a `<select>`, which hid both how many providers exist and which of
|
|
15
|
+
* them actually work. The card carries that state, so the failing one is
|
|
16
|
+
* visible without opening anything.
|
|
17
|
+
*/
|
|
18
|
+
function ProviderPicker({
|
|
19
|
+
providers,
|
|
20
|
+
configs,
|
|
21
|
+
activeName,
|
|
22
|
+
onSelect,
|
|
23
|
+
}: {
|
|
24
|
+
providers: ProviderDescriptor[];
|
|
25
|
+
configs: MaskedProviderConfig[];
|
|
26
|
+
activeName: string | null;
|
|
27
|
+
onSelect: (name: string) => void;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<Box
|
|
31
|
+
data-testid="payments-provider-picker"
|
|
32
|
+
sx={{
|
|
33
|
+
display: 'grid',
|
|
34
|
+
gap: 2,
|
|
35
|
+
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
|
|
36
|
+
mb: 3,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{providers.map((provider) => {
|
|
40
|
+
const config = configs.find((c) => c.provider === provider.name) ?? null;
|
|
41
|
+
const badge = connectionBadge(config);
|
|
42
|
+
const selected = provider.name === activeName;
|
|
43
|
+
return (
|
|
44
|
+
<Box
|
|
45
|
+
key={provider.name}
|
|
46
|
+
component="button"
|
|
47
|
+
type="button"
|
|
48
|
+
aria-pressed={selected}
|
|
49
|
+
data-testid={`payments-provider-card-${provider.name}`}
|
|
50
|
+
onClick={() => onSelect(provider.name)}
|
|
51
|
+
sx={{
|
|
52
|
+
display: 'flex',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
justifyContent: 'space-between',
|
|
55
|
+
gap: 1,
|
|
56
|
+
width: '100%',
|
|
57
|
+
p: 2,
|
|
58
|
+
cursor: 'pointer',
|
|
59
|
+
textAlign: 'left',
|
|
60
|
+
font: 'inherit',
|
|
61
|
+
borderRadius: 1,
|
|
62
|
+
border: 2,
|
|
63
|
+
borderColor: selected ? 'primary.main' : 'divider',
|
|
64
|
+
bgcolor: selected ? 'action.selected' : 'background.paper',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<Typography variant="subtitle1">{provider.displayName}</Typography>
|
|
68
|
+
<Chip
|
|
69
|
+
size="small"
|
|
70
|
+
data-testid={`payments-provider-badge-${provider.name}`}
|
|
71
|
+
label={badge.label}
|
|
72
|
+
color={badge.color}
|
|
73
|
+
/>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</Box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The landing view: choose a provider. Nothing is configured until one is
|
|
83
|
+
* picked, so this screen carries only the choice.
|
|
84
|
+
*/
|
|
85
|
+
export function ProviderList({
|
|
86
|
+
view,
|
|
87
|
+
client,
|
|
88
|
+
reload,
|
|
89
|
+
onSelect,
|
|
90
|
+
}: {
|
|
91
|
+
view: MerchantSettingsView;
|
|
92
|
+
client: PaymentsSettingsClient;
|
|
93
|
+
reload: () => Promise<void>;
|
|
94
|
+
onSelect: (name: string) => void;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<Box data-testid="payments-provider-settings">
|
|
98
|
+
{/*
|
|
99
|
+
The failover chain orders providers BETWEEN each other, so it belongs on
|
|
100
|
+
the list — but only once there is an order to argue about. On a first
|
|
101
|
+
visit it would be an empty box above the only thing that matters.
|
|
102
|
+
Remounted on chain change so drag state starts from server truth.
|
|
103
|
+
*/}
|
|
104
|
+
{chainOf(view).length > 1 ? (
|
|
105
|
+
<Box sx={{ mb: 3 }}>
|
|
106
|
+
<ProviderPriorityList
|
|
107
|
+
key={chainOf(view).join(',')}
|
|
108
|
+
view={view}
|
|
109
|
+
client={client}
|
|
110
|
+
onReordered={() => void reload()}
|
|
111
|
+
/>
|
|
112
|
+
</Box>
|
|
113
|
+
) : null}
|
|
114
|
+
<Typography variant="subtitle1">Escolha o provedor de pagamento</Typography>
|
|
115
|
+
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
116
|
+
Selecione onde sua loja recebe — a configuração é feita sob medida para ele.
|
|
117
|
+
</Typography>
|
|
118
|
+
<ProviderPicker
|
|
119
|
+
providers={view.providers}
|
|
120
|
+
configs={view.configs}
|
|
121
|
+
activeName={null}
|
|
122
|
+
onSelect={onSelect}
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Accordion,
|
|
5
|
+
AccordionDetails,
|
|
6
|
+
AccordionSummary,
|
|
7
|
+
Alert,
|
|
8
|
+
Box,
|
|
9
|
+
Paper,
|
|
10
|
+
Stack,
|
|
11
|
+
Typography,
|
|
12
|
+
} from '@mui/material';
|
|
13
|
+
import { useCallback, useState, type ReactNode } from 'react';
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
MaskedProviderConfig,
|
|
17
|
+
PaymentEnvironment,
|
|
18
|
+
ProviderDescriptor,
|
|
19
|
+
} from '@12-apps/payments-backend';
|
|
20
|
+
|
|
21
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
22
|
+
import { EnvironmentNotice, EnvironmentSelector } from './EnvironmentTabs';
|
|
23
|
+
import { ProviderConnection } from './ProviderConnection';
|
|
24
|
+
import { ProviderForm } from './ProviderCredentialForm';
|
|
25
|
+
import { ProviderStatusBar } from './ProviderStatusBar';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One provider's configuration panel: the Ativo bar, the connect card or the
|
|
29
|
+
* credential form (per the provider's authMode), the host's activation step,
|
|
30
|
+
* and the manual-credentials fallback.
|
|
31
|
+
*
|
|
32
|
+
* Split out of `PaymentProviderSettings` so that file stays about WHICH
|
|
33
|
+
* provider is open; this one is about what an open provider looks like.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Host endpoint that mints and persists the CSRF state against the admin
|
|
38
|
+
* session and returns it with the callback URL to come back to.
|
|
39
|
+
*/
|
|
40
|
+
export type PrepareConnect = (
|
|
41
|
+
provider: string,
|
|
42
|
+
environment: PaymentEnvironment,
|
|
43
|
+
) => Promise<{ state: string; redirectUri: string; environment?: PaymentEnvironment }>;
|
|
44
|
+
|
|
45
|
+
export interface ActivePanelProps {
|
|
46
|
+
descriptor: ProviderDescriptor;
|
|
47
|
+
config: MaskedProviderConfig | null;
|
|
48
|
+
client: PaymentsSettingsClient;
|
|
49
|
+
onChanged?: (config: MaskedProviderConfig) => void;
|
|
50
|
+
reload: () => void;
|
|
51
|
+
prepareConnect?: PrepareConnect;
|
|
52
|
+
/**
|
|
53
|
+
* The provider's walkthrough, as a function of the form's own pieces.
|
|
54
|
+
*
|
|
55
|
+
* It LEADS on the credentials path — the stepper and the step still owed both
|
|
56
|
+
* answer "where am I", which is a question you ask before typing — and it
|
|
57
|
+
* physically contains the field for that step, because "informe sua
|
|
58
|
+
* InfiniteTag" and the box you type it into are one thing.
|
|
59
|
+
*
|
|
60
|
+
* On the OAUTH path the whole assembly stays inside the manual disclosure. It
|
|
61
|
+
* is written for someone pasting credentials by hand, so there it is a wall
|
|
62
|
+
* of instructions contradicting the card above it, which says no key needs
|
|
63
|
+
* copying. Reachable, just not the first thing read.
|
|
64
|
+
*/
|
|
65
|
+
guide?: (slots: {
|
|
66
|
+
rows: ReactNode;
|
|
67
|
+
sectionFooter: ReactNode;
|
|
68
|
+
editing: boolean;
|
|
69
|
+
/** The environment on screen holds its credentials — see `renderGuide`. */
|
|
70
|
+
stored: boolean;
|
|
71
|
+
}) => ReactNode;
|
|
72
|
+
/** The host's activation step (see `renderVerification`), already resolved. */
|
|
73
|
+
verification?: ReactNode;
|
|
74
|
+
/** The stored credentials were replaced — see `ProviderForm`. */
|
|
75
|
+
onCredentialsReplaced?: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Credential form or OAuth connection card, per the provider's authMode.
|
|
80
|
+
*
|
|
81
|
+
* An OAuth provider gets BOTH: the connect button as the happy path, plus the
|
|
82
|
+
* credential form tucked behind a disclosure. That fallback is not decoration
|
|
83
|
+
* — stores connected before Connect existed still hold a pasted token, and a
|
|
84
|
+
* deployment with no registered provider application has no working connect
|
|
85
|
+
* button at all. Hiding the form outright would strand both.
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* The status chip + "Ativo" switch, owning its own in-flight state.
|
|
89
|
+
*
|
|
90
|
+
* Rendered above BOTH branches of {@link ActivePanel}, never inside the
|
|
91
|
+
* manual-credentials disclosure: this is the control that decides whether
|
|
92
|
+
* checkout can charge, and an OAuth-connected store — told on the card above
|
|
93
|
+
* that no key needs copying — has no reason to open that disclosure at all.
|
|
94
|
+
*/
|
|
95
|
+
function EnableBar({ descriptor, config, client, onChanged, reload }: ActivePanelProps) {
|
|
96
|
+
const [toggling, setToggling] = useState(false);
|
|
97
|
+
const toggle = useCallback(
|
|
98
|
+
async (enabled: boolean) => {
|
|
99
|
+
setToggling(true);
|
|
100
|
+
try {
|
|
101
|
+
const next = await client.setEnabled(descriptor.name, enabled);
|
|
102
|
+
onChanged?.(next);
|
|
103
|
+
reload();
|
|
104
|
+
} finally {
|
|
105
|
+
setToggling(false);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
[client, descriptor.name, onChanged, reload],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<ProviderStatusBar
|
|
113
|
+
descriptor={descriptor}
|
|
114
|
+
config={config}
|
|
115
|
+
busy={toggling}
|
|
116
|
+
onToggle={(enabled) => void toggle(enabled)}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One provider, one card — in three bands.
|
|
123
|
+
*
|
|
124
|
+
* The screen used to be a column of loose blocks on the page background, and
|
|
125
|
+
* with the environment tabs halfway down it it was genuinely ambiguous what
|
|
126
|
+
* they governed: the fields under them, or everything. Bounding the provider
|
|
127
|
+
* makes the answer structural — inside this card, one provider, one
|
|
128
|
+
* environment.
|
|
129
|
+
*
|
|
130
|
+
* The three bands exist for the middle one. The environment banner has to run
|
|
131
|
+
* EDGE TO EDGE, because a strip that spans the card reads as a property of
|
|
132
|
+
* everything below it while the same words inset by the card's padding read as
|
|
133
|
+
* one more paragraph of content. That is not achievable inside a single padded
|
|
134
|
+
* container, so the header and the body carry the padding and the band between
|
|
135
|
+
* them carries none.
|
|
136
|
+
*/
|
|
137
|
+
function ProviderCard({
|
|
138
|
+
header,
|
|
139
|
+
band,
|
|
140
|
+
children,
|
|
141
|
+
}: {
|
|
142
|
+
header: ReactNode;
|
|
143
|
+
band?: ReactNode;
|
|
144
|
+
children: ReactNode;
|
|
145
|
+
}) {
|
|
146
|
+
return (
|
|
147
|
+
<Paper variant="outlined">
|
|
148
|
+
<Box sx={{ px: 3, pt: 3 }}>{header}</Box>
|
|
149
|
+
{band}
|
|
150
|
+
<Box sx={{ p: 3 }}>
|
|
151
|
+
<Stack spacing={2}>{children}</Stack>
|
|
152
|
+
</Box>
|
|
153
|
+
</Paper>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every REQUIRED credential is on record for this environment specifically. */
|
|
158
|
+
function requiredStored(
|
|
159
|
+
descriptor: ProviderDescriptor,
|
|
160
|
+
config: MaskedProviderConfig | null,
|
|
161
|
+
environment: PaymentEnvironment,
|
|
162
|
+
): boolean {
|
|
163
|
+
const stored = config?.environments[environment] ?? {};
|
|
164
|
+
return descriptor.credentialSchema
|
|
165
|
+
.filter((spec) => spec.required)
|
|
166
|
+
.every((spec) => stored[spec.key]?.configured === true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function ActivePanel(props: ActivePanelProps) {
|
|
170
|
+
const { descriptor, config, client, onChanged, reload, guide, verification } = props;
|
|
171
|
+
const statusBar = <EnableBar {...props} />;
|
|
172
|
+
// The environment frames everything below it, so the PANEL owns the choice
|
|
173
|
+
// and puts the tabs at the top — not the form, halfway down its own column.
|
|
174
|
+
const [environment, setEnvironment] = useState<PaymentEnvironment>(
|
|
175
|
+
config?.environment ?? 'SANDBOX',
|
|
176
|
+
);
|
|
177
|
+
// Lifted out of the form because it decides more than the form: reopening
|
|
178
|
+
// step 1 must also take step 3 off the screen. A card offering to charge
|
|
179
|
+
// R$ 1,01 under a heading about typing your InfiniteTag is two steps at once,
|
|
180
|
+
// and the one that costs money is the one you did not ask for.
|
|
181
|
+
const [editing, setEditing] = useState(false);
|
|
182
|
+
|
|
183
|
+
const credentials = (
|
|
184
|
+
<ProviderForm
|
|
185
|
+
descriptor={descriptor}
|
|
186
|
+
config={config}
|
|
187
|
+
client={client}
|
|
188
|
+
environment={environment}
|
|
189
|
+
editing={editing}
|
|
190
|
+
onEditingChange={setEditing}
|
|
191
|
+
onChanged={onChanged}
|
|
192
|
+
onSaved={reload}
|
|
193
|
+
onCredentialsReplaced={props.onCredentialsReplaced}
|
|
194
|
+
renderGuide={guide}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const selector = <EnvironmentSelector environment={environment} onChange={setEnvironment} />;
|
|
199
|
+
const band = <EnvironmentNotice environment={environment} active={config?.environment ?? null} />;
|
|
200
|
+
|
|
201
|
+
// The activation step charges through the credentials of the environment it
|
|
202
|
+
// is under. Shown on a tab that stores none, it offers to charge an account
|
|
203
|
+
// that is not there — on the screen whose entire subject is which account
|
|
204
|
+
// receives the store's money. `config.environment` is the ACTIVE one, so it
|
|
205
|
+
// cannot answer this; the fields on screen can.
|
|
206
|
+
const storedHere = requiredStored(descriptor, config, environment);
|
|
207
|
+
|
|
208
|
+
if (descriptor.authMode !== 'oauth') {
|
|
209
|
+
return (
|
|
210
|
+
<ProviderCard
|
|
211
|
+
header={
|
|
212
|
+
<Stack spacing={2}>
|
|
213
|
+
{statusBar}
|
|
214
|
+
{selector}
|
|
215
|
+
</Stack>
|
|
216
|
+
}
|
|
217
|
+
band={band}
|
|
218
|
+
>
|
|
219
|
+
{credentials}
|
|
220
|
+
{editing || !storedHere ? null : verification}
|
|
221
|
+
</ProviderCard>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<OAuthPanel
|
|
227
|
+
{...props}
|
|
228
|
+
statusBar={statusBar}
|
|
229
|
+
form={
|
|
230
|
+
<Stack spacing={2}>
|
|
231
|
+
{selector}
|
|
232
|
+
{band}
|
|
233
|
+
{credentials}
|
|
234
|
+
</Stack>
|
|
235
|
+
}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The OAuth path: a connect button as the happy path, with the credential form
|
|
242
|
+
* kept behind a disclosure.
|
|
243
|
+
*
|
|
244
|
+
* That fallback is not decoration — stores connected before Connect existed
|
|
245
|
+
* still hold a pasted token, and a deployment with no registered provider
|
|
246
|
+
* application has no working connect button at all. Hiding the form outright
|
|
247
|
+
* would strand both.
|
|
248
|
+
*/
|
|
249
|
+
function OAuthPanel({
|
|
250
|
+
descriptor,
|
|
251
|
+
config,
|
|
252
|
+
client,
|
|
253
|
+
reload,
|
|
254
|
+
prepareConnect,
|
|
255
|
+
verification,
|
|
256
|
+
statusBar,
|
|
257
|
+
form,
|
|
258
|
+
}: ActivePanelProps & { statusBar: ReactNode; form: ReactNode }) {
|
|
259
|
+
if (!prepareConnect) {
|
|
260
|
+
return (
|
|
261
|
+
<ProviderCard header={statusBar}>
|
|
262
|
+
<Alert severity="info">
|
|
263
|
+
Este provedor conecta por autorização, mas o botão de conexão não está disponível nesta
|
|
264
|
+
instalação. Você ainda pode conectar informando as credenciais manualmente.
|
|
265
|
+
</Alert>
|
|
266
|
+
{form}
|
|
267
|
+
{verification}
|
|
268
|
+
</ProviderCard>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<ProviderCard header={statusBar}>
|
|
274
|
+
<ProviderConnection
|
|
275
|
+
descriptor={descriptor}
|
|
276
|
+
config={config}
|
|
277
|
+
client={client}
|
|
278
|
+
prepareConnect={prepareConnect}
|
|
279
|
+
onChanged={reload}
|
|
280
|
+
/>
|
|
281
|
+
{/*
|
|
282
|
+
Directly under the connect card, ABOVE the manual disclosure: this is
|
|
283
|
+
the step that actually turns the store on, and an owner who just
|
|
284
|
+
authorized has no reason to open "prefiro informar as credenciais
|
|
285
|
+
manualmente" to find it.
|
|
286
|
+
*/}
|
|
287
|
+
{verification}
|
|
288
|
+
{descriptor.credentialSchema.length > 0 ? (
|
|
289
|
+
<Accordion disableGutters data-testid="payments-manual-fallback">
|
|
290
|
+
<AccordionSummary expandIcon={<span aria-hidden>▾</span>}>
|
|
291
|
+
<Typography variant="body2" color="text.secondary">
|
|
292
|
+
Prefiro informar as credenciais manualmente
|
|
293
|
+
</Typography>
|
|
294
|
+
</AccordionSummary>
|
|
295
|
+
<AccordionDetails>{form}</AccordionDetails>
|
|
296
|
+
</Accordion>
|
|
297
|
+
) : null}
|
|
298
|
+
</ProviderCard>
|
|
299
|
+
);
|
|
300
|
+
}
|