@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.
Files changed (34) hide show
  1. package/eslint.config.js +34 -0
  2. package/package.json +63 -0
  3. package/src/__tests__/checkout-confirmation.test.tsx +177 -0
  4. package/src/__tests__/connection-state.test.tsx +84 -0
  5. package/src/__tests__/context.test.tsx +88 -0
  6. package/src/__tests__/controlled-provider.test.tsx +116 -0
  7. package/src/__tests__/credential-confirm.test.tsx +193 -0
  8. package/src/__tests__/initial-provider.test.tsx +81 -0
  9. package/src/__tests__/provider-priority-list.test.tsx +159 -0
  10. package/src/__tests__/provider-status-bar.test.tsx +152 -0
  11. package/src/__tests__/verification-slot.test.tsx +125 -0
  12. package/src/client.ts +200 -0
  13. package/src/components/CheckoutFlow.tsx +169 -0
  14. package/src/components/CheckoutPayment.tsx +379 -0
  15. package/src/components/ConfirmCredentialSave.tsx +106 -0
  16. package/src/components/CredentialFields.tsx +158 -0
  17. package/src/components/CredentialFormAlerts.tsx +144 -0
  18. package/src/components/EnvironmentTabs.tsx +109 -0
  19. package/src/components/PaymentProviderSettings.tsx +267 -0
  20. package/src/components/ProviderConnection.tsx +251 -0
  21. package/src/components/ProviderCredentialForm.tsx +387 -0
  22. package/src/components/ProviderList.tsx +126 -0
  23. package/src/components/ProviderPanel.tsx +300 -0
  24. package/src/components/ProviderPriorityList.tsx +293 -0
  25. package/src/components/ProviderSetupGuide.tsx +263 -0
  26. package/src/components/ProviderStatusBar.tsx +192 -0
  27. package/src/components/SetupGuideSection.tsx +190 -0
  28. package/src/components/checkout-ack.ts +106 -0
  29. package/src/components/connection-state.ts +66 -0
  30. package/src/components/credential-rules.ts +120 -0
  31. package/src/components/rich-text.tsx +27 -0
  32. package/src/components/settings-state.ts +211 -0
  33. package/src/context.tsx +144 -0
  34. 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
+ }