@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,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Chip, FormControlLabel, Stack, Switch, Tooltip, Typography } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
import { isConnected } from './connection-state';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The provider's headline: what state it is in, and the switch that decides
|
|
11
|
+
* whether real buyers are sent to it.
|
|
12
|
+
*
|
|
13
|
+
* Its own file since the credential form grew past what one module should
|
|
14
|
+
* carry, and because this bar is rendered above BOTH connection branches —
|
|
15
|
+
* it belongs to the provider, not to the form.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What the chip says, in the owner's language, and what it is claiming. */
|
|
19
|
+
interface StatusBadge {
|
|
20
|
+
label: string;
|
|
21
|
+
color: 'default' | 'info' | 'success' | 'error';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Three different facts used to share one word.
|
|
26
|
+
*
|
|
27
|
+
* The chip printed the stored `status` verbatim, so a store that had passed
|
|
28
|
+
* "Testar conexão" read `VERIFIED` — and `VERIFIED` is a claim about
|
|
29
|
+
* CREDENTIALS AUTHENTICATING, nothing more. PagBank answers yes to it while
|
|
30
|
+
* refusing every real charge until homologação; InfinitePay answers yes with
|
|
31
|
+
* Checkout Integrado switched off, which creates no links at all. An owner
|
|
32
|
+
* reading "verified" reasonably concludes the store can take money.
|
|
33
|
+
*
|
|
34
|
+
* So the technical fact and the money fact get different words. `CONEXÃO OK`
|
|
35
|
+
* says the account was reached. `VERIFICADO` is reserved for `chargeVerifiedAt`
|
|
36
|
+
* — a real payment that actually landed — and is the only one painted green.
|
|
37
|
+
*/
|
|
38
|
+
export function statusBadge(
|
|
39
|
+
config: MaskedProviderConfig | null,
|
|
40
|
+
descriptor?: ProviderDescriptor,
|
|
41
|
+
): StatusBadge {
|
|
42
|
+
if (config?.chargeVerifiedAt) return { label: 'VERIFICADO', color: 'success' };
|
|
43
|
+
// A store that has entered nothing has not FAILED at anything. The stored
|
|
44
|
+
// status outlives the credential it described — clearing the field leaves the
|
|
45
|
+
// last verdict behind — so a red FALHOU sat above an empty form, accusing the
|
|
46
|
+
// owner of a mistake they had not yet had the chance to make.
|
|
47
|
+
if (descriptor && !anyCredentialStored(config, descriptor)) {
|
|
48
|
+
return { label: 'NÃO VERIFICADO', color: 'default' };
|
|
49
|
+
}
|
|
50
|
+
if (config?.status === 'RECONNECT_REQUIRED') return { label: 'RECONECTAR', color: 'error' };
|
|
51
|
+
if (isConnected(config)) return { label: 'CONEXÃO OK', color: 'info' };
|
|
52
|
+
// A FAILED probe lands here, and it says NÃO VERIFICADO like every other
|
|
53
|
+
// not-yet-connected state. `FALHOU` was a third word for the same fact — the
|
|
54
|
+
// store cannot take money through this provider yet — and it was the only
|
|
55
|
+
// one painted red, so a mistyped InfiniteTag read as a system fault rather
|
|
56
|
+
// than as a step still to finish. The probe's own sentence is directly
|
|
57
|
+
// underneath and says WHICH tag and WHAT to do about it, which no chip can.
|
|
58
|
+
//
|
|
59
|
+
// `RECONNECT_REQUIRED` stays red on purpose: that one is a connection that
|
|
60
|
+
// WAS working and stopped, which is news rather than a step.
|
|
61
|
+
return { label: 'NÃO VERIFICADO', color: 'default' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does this provider hold any credential at all in the environment in use?
|
|
66
|
+
*
|
|
67
|
+
* Only meaningful for providers that HAVE a credential form: under OAuth the
|
|
68
|
+
* grant is the credential and no field is ever filled, so those keep whatever
|
|
69
|
+
* the probe last said.
|
|
70
|
+
*/
|
|
71
|
+
function anyCredentialStored(
|
|
72
|
+
config: MaskedProviderConfig | null,
|
|
73
|
+
descriptor: ProviderDescriptor,
|
|
74
|
+
): boolean {
|
|
75
|
+
// `?? []` because a descriptor may legitimately declare no schema at all, and
|
|
76
|
+
// a status chip must never be the thing that throws on a settings screen.
|
|
77
|
+
if ((descriptor.credentialSchema ?? []).length === 0) return true;
|
|
78
|
+
if (!config) return false;
|
|
79
|
+
const stored = config.environments[config.environment] ?? {};
|
|
80
|
+
return Object.values(stored).some((field) => field.configured);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* May the switch be touched, and if not, why not?
|
|
85
|
+
*
|
|
86
|
+
* Activation is EARNED BY CHARGING. The status chip cannot be the gate, and
|
|
87
|
+
* that is the whole lesson: `VERIFIED` comes from the credential probe, which
|
|
88
|
+
* only asks whether the keys authenticate. A PagBank Connect grant answers yes
|
|
89
|
+
* to that while refusing every real charge with `403 ACCESS_DENIED` until the
|
|
90
|
+
* integration is homologated — so a store read `PagBank [VERIFIED] [on] Ativo`
|
|
91
|
+
* and declined every shopper.
|
|
92
|
+
*
|
|
93
|
+
* Two qualifications, both learned the hard way:
|
|
94
|
+
*
|
|
95
|
+
* - Only providers that CAN charge are held to having charged. Stripe and
|
|
96
|
+
* InfinitePay have no browser tokenization written, so requiring the proof
|
|
97
|
+
* would not make their "Ativo" honest — it would make them unactivatable.
|
|
98
|
+
* - Turning OFF is never blocked. An owner must be able to pull a provider out
|
|
99
|
+
* of rotation at once, and rows enabled before this rule existed would
|
|
100
|
+
* otherwise be stuck on with no way down.
|
|
101
|
+
*
|
|
102
|
+
* The hint names no amount. It used to promise "a cobrança de teste de R$ 0,01"
|
|
103
|
+
* while the step it points at charges R$ 1,01 for InfinitePay, which refuses a
|
|
104
|
+
* one-cent total outright — the figure is a per-provider fact this component
|
|
105
|
+
* does not have, and quoting it from here could only ever be a guess.
|
|
106
|
+
*/
|
|
107
|
+
function toggleGate(
|
|
108
|
+
descriptor: ProviderDescriptor,
|
|
109
|
+
config: MaskedProviderConfig | null,
|
|
110
|
+
enabled: boolean,
|
|
111
|
+
): { lockedOff: boolean; hint: string } {
|
|
112
|
+
const provable = descriptor.capabilities?.activationCharge === true;
|
|
113
|
+
// `chargeVerifiedAt` PERSISTS once set, so an owner who paused a proven
|
|
114
|
+
// provider can resume it without charging a second time.
|
|
115
|
+
const ready = provable ? Boolean(config?.chargeVerifiedAt) : isConnected(config);
|
|
116
|
+
const lockedOff = !ready && !enabled;
|
|
117
|
+
if (!lockedOff) return { lockedOff, hint: '' };
|
|
118
|
+
return {
|
|
119
|
+
lockedOff,
|
|
120
|
+
hint: provable
|
|
121
|
+
? 'Este provedor só passa a receber vendas depois dos 3 passos abaixo.'
|
|
122
|
+
: 'Conecte e verifique o provedor antes de ativar as vendas.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Status chip + the "recebendo vendas" switch, hoisted OUT of the credential
|
|
128
|
+
* form.
|
|
129
|
+
*
|
|
130
|
+
* These belong to the provider, not to the form: a store that connected by
|
|
131
|
+
* OAuth is told no key needs copying, so it has no reason to open the
|
|
132
|
+
* "Prefiro informar as credenciais manualmente" disclosure — and while this
|
|
133
|
+
* lived inside it, the switch that actually lets checkout charge was hidden in
|
|
134
|
+
* there. The connect card said "conectado" and the page said "nenhum provedor
|
|
135
|
+
* está ativo", with the control to reconcile them out of sight.
|
|
136
|
+
*
|
|
137
|
+
* The switch is labelled with its CONSEQUENCE rather than with "Ativo". "Ativo"
|
|
138
|
+
* is a word about a database row; "Recebendo vendas" is the thing the owner is
|
|
139
|
+
* deciding, and the difference matters most in the state where the control is
|
|
140
|
+
* dead — where "Ativo" plus a greyed-out toggle reads as a broken screen unless
|
|
141
|
+
* you hover it. The reason is printed underneath instead of hidden in a
|
|
142
|
+
* tooltip, since the owner who most needs it is the one who cannot click.
|
|
143
|
+
*/
|
|
144
|
+
export function ProviderStatusBar({
|
|
145
|
+
descriptor,
|
|
146
|
+
config,
|
|
147
|
+
busy,
|
|
148
|
+
onToggle,
|
|
149
|
+
}: {
|
|
150
|
+
descriptor: ProviderDescriptor;
|
|
151
|
+
config: MaskedProviderConfig | null;
|
|
152
|
+
busy: boolean;
|
|
153
|
+
onToggle: (enabled: boolean) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const enabled = config?.enabled ?? false;
|
|
156
|
+
const { lockedOff, hint } = toggleGate(descriptor, config, enabled);
|
|
157
|
+
const badge = statusBadge(config, descriptor);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Stack spacing={0.5}>
|
|
161
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
162
|
+
<Typography variant="h6">{descriptor.displayName}</Typography>
|
|
163
|
+
<Chip size="small" data-testid="payments-status" label={badge.label} color={badge.color} />
|
|
164
|
+
{/* The switch belongs at the far edge: it is the one control here that
|
|
165
|
+
changes what buyers experience, and crowding it against the status
|
|
166
|
+
chip made the two read as one compound widget. */}
|
|
167
|
+
<Box sx={{ flexGrow: 1 }} />
|
|
168
|
+
<Tooltip title={hint}>
|
|
169
|
+
{/* A disabled control fires no events, so the tooltip needs a live wrapper. */}
|
|
170
|
+
<span>
|
|
171
|
+
<FormControlLabel
|
|
172
|
+
control={
|
|
173
|
+
<Switch
|
|
174
|
+
data-testid="payments-enabled-toggle"
|
|
175
|
+
checked={enabled}
|
|
176
|
+
disabled={busy || lockedOff}
|
|
177
|
+
onChange={(_, next) => onToggle(next)}
|
|
178
|
+
/>
|
|
179
|
+
}
|
|
180
|
+
label={enabled ? 'Recebendo vendas' : 'Não está recebendo'}
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
</Tooltip>
|
|
184
|
+
</Stack>
|
|
185
|
+
{lockedOff ? (
|
|
186
|
+
<Typography variant="caption" color="text.secondary" data-testid="payments-enable-hint">
|
|
187
|
+
{hint}
|
|
188
|
+
</Typography>
|
|
189
|
+
) : null}
|
|
190
|
+
</Stack>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box } from '@mui/material';
|
|
4
|
+
import type { JSX, ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ProviderSetupGuide as Guide, SetupSection } from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import { DoneRow } from './CredentialFields';
|
|
9
|
+
import { ProviderSetupGuide } from './ProviderSetupGuide';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The walkthrough, plus the one step whose completion only the OWNER can
|
|
13
|
+
* report.
|
|
14
|
+
*
|
|
15
|
+
* ## The action id is a contract, like PagBank's
|
|
16
|
+
*
|
|
17
|
+
* `SetupStep.action` has always been an opaque id an adapter and a host agree
|
|
18
|
+
* on out of band — PagBank's `homologacao-anexo` asks the host to generate the
|
|
19
|
+
* evidence file its review form wants. This is the second such agreement, and
|
|
20
|
+
* the first whose answer is a FACT rather than a side effect: InfinitePay ships
|
|
21
|
+
* Checkout Integrado disabled and publishes nothing that reports its state, so
|
|
22
|
+
* the only reading available is the owner's.
|
|
23
|
+
*
|
|
24
|
+
* A guide that offers this action is asking "have you done this?", and a host
|
|
25
|
+
* that handles it owes an answer it can remember.
|
|
26
|
+
*/
|
|
27
|
+
export const CHECKOUT_CONFIRM_ACTION = 'checkout-integrado-confirmado';
|
|
28
|
+
|
|
29
|
+
/** Does this section end in a question only the owner can answer? */
|
|
30
|
+
function isConfirmable(section: SetupSection): boolean {
|
|
31
|
+
return section.steps.some((step) => step.action === CHECKOUT_CONFIRM_ACTION);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Which stage the walkthrough is REALLY on.
|
|
36
|
+
*
|
|
37
|
+
* The server reports what it can prove — credentials that reach an account, a
|
|
38
|
+
* charge that landed — and that is not the whole answer. One step in the middle
|
|
39
|
+
* can only be confirmed by the owner, because no API reports whether Checkout
|
|
40
|
+
* Integrado is switched on. Left to the server's number alone the screen ticked
|
|
41
|
+
* that step off and jumped to step 3 the moment the probe passed, having never
|
|
42
|
+
* shown it.
|
|
43
|
+
*
|
|
44
|
+
* So an unconfirmed confirmable section holds the walkthrough where it is. And
|
|
45
|
+
* `editing` beats everything: reopening a finished step means going back to it.
|
|
46
|
+
*/
|
|
47
|
+
function effectiveStage(
|
|
48
|
+
guide: Guide,
|
|
49
|
+
confirmed: boolean,
|
|
50
|
+
editing: boolean,
|
|
51
|
+
stored = true,
|
|
52
|
+
): number {
|
|
53
|
+
const stage = guide.activeStage ?? 0;
|
|
54
|
+
if (editing) return 0;
|
|
55
|
+
// Nothing stored for the environment being LOOKED AT ⇒ step 1, whatever the
|
|
56
|
+
// active environment has achieved. `activeStage` is computed from the config
|
|
57
|
+
// the store actually charges with, so on the other tab it reported progress
|
|
58
|
+
// made somewhere else — a walkthrough claiming steps 1 and 2 were done over
|
|
59
|
+
// an empty Produção field, on the screen whose whole subject is which
|
|
60
|
+
// account receives the money.
|
|
61
|
+
if (!stored) return 0;
|
|
62
|
+
if (confirmed) return stage;
|
|
63
|
+
const waiting = confirmableStage(guide);
|
|
64
|
+
return waiting < 0 ? stage : Math.min(stage, waiting);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Index of the stage whose section ends in a question only the owner can answer. */
|
|
68
|
+
function confirmableStage(guide: Guide): number {
|
|
69
|
+
return guide.stages.findIndex((stage) => {
|
|
70
|
+
const section = guide.sections.find((candidate) => candidate.id === stage.id);
|
|
71
|
+
return section ? isConfirmable(section) : false;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The section the walkthrough is showing, or null when it has nothing left.
|
|
77
|
+
*
|
|
78
|
+
* Exported because the ACTIVATION STEP needs the same answer: it is the last
|
|
79
|
+
* stage, so it may only appear once the guide is out of sections. Computing it
|
|
80
|
+
* twice is how the screen ended up rendering "Passo 3 · Ative as vendas" —
|
|
81
|
+
* complete with its pay button — directly under "Passo 2".
|
|
82
|
+
*/
|
|
83
|
+
export function openSection(
|
|
84
|
+
guide: Guide | null,
|
|
85
|
+
confirmed: boolean,
|
|
86
|
+
editing: boolean,
|
|
87
|
+
stored = true,
|
|
88
|
+
) {
|
|
89
|
+
if (!guide) return null;
|
|
90
|
+
return sectionAt(guide, effectiveStage(guide, confirmed, editing, stored));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The section paired with a stage, when the adapter ships one for it. */
|
|
94
|
+
function sectionAt(guide: Guide, stage: number): SetupSection | null {
|
|
95
|
+
const wanted = guide.stages[stage];
|
|
96
|
+
if (!wanted) return null;
|
|
97
|
+
return guide.sections.find((section) => section.id === wanted.id) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SetupGuideSectionProps {
|
|
101
|
+
guide: Guide | null;
|
|
102
|
+
/** The owner has confirmed the step that only they can see. */
|
|
103
|
+
confirmed: boolean;
|
|
104
|
+
onConfirm: () => void;
|
|
105
|
+
/** Re-open the confirmed step — the owner wants to check it again. */
|
|
106
|
+
onReopen: () => void;
|
|
107
|
+
/** Rows for steps finished elsewhere (a saved credential), shown alongside. */
|
|
108
|
+
rows?: ReactNode;
|
|
109
|
+
/** The live control for the open step — the credential form. */
|
|
110
|
+
sectionFooter?: ReactNode;
|
|
111
|
+
/** The owner has reopened a finished step; show that step, not the current one. */
|
|
112
|
+
editing?: boolean;
|
|
113
|
+
/** The environment on screen holds its credentials — see `renderGuide`. */
|
|
114
|
+
stored?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A confirmed step collapses to one line, exactly as a saved credential does.
|
|
119
|
+
*
|
|
120
|
+
* Not removed: "I told you this was on" is the claim step 3 is about to test,
|
|
121
|
+
* and when the provider then refuses to mint a link, the owner needs the row
|
|
122
|
+
* still there to press Revisar on.
|
|
123
|
+
*/
|
|
124
|
+
function ConfirmedRow({ section, onReopen }: { section: SetupSection; onReopen: () => void }) {
|
|
125
|
+
return (
|
|
126
|
+
<DoneRow
|
|
127
|
+
testId="payments-setup-confirmed"
|
|
128
|
+
label={section.doneSummary?.label ?? section.title}
|
|
129
|
+
value={section.doneSummary?.value ?? 'Confirmado por você'}
|
|
130
|
+
editLabel="Revisar"
|
|
131
|
+
onEdit={onReopen}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function SetupGuideSection({
|
|
137
|
+
guide,
|
|
138
|
+
confirmed,
|
|
139
|
+
onConfirm,
|
|
140
|
+
onReopen,
|
|
141
|
+
rows,
|
|
142
|
+
sectionFooter,
|
|
143
|
+
editing = false,
|
|
144
|
+
stored = true,
|
|
145
|
+
}: SetupGuideSectionProps): JSX.Element {
|
|
146
|
+
// No walkthrough — PagBank ships none, because under Connect the platform is
|
|
147
|
+
// reviewed centrally and the owner has nothing to follow. The SLOTS still
|
|
148
|
+
// have to render: they are the credential form, and swallowing them left a
|
|
149
|
+
// provider with a status chip, a pair of environment tabs and no way to type
|
|
150
|
+
// anything in.
|
|
151
|
+
if (!guide) {
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
{rows}
|
|
155
|
+
{sectionFooter}
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stage = effectiveStage(guide, confirmed, editing, stored);
|
|
161
|
+
const open = sectionAt(guide, stage);
|
|
162
|
+
// The confirmed step keeps a row of its own once the walkthrough has moved
|
|
163
|
+
// past it — it is the claim step 3 is about to test, and when the provider
|
|
164
|
+
// refuses to mint a link the owner needs somewhere to press Revisar.
|
|
165
|
+
const settledStage = confirmableStage(guide);
|
|
166
|
+
const settled = confirmed && !editing && settledStage >= 0 && stage > settledStage;
|
|
167
|
+
const settledSection = settled ? sectionAt(guide, settledStage) : null;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<Box data-testid="payments-setup">
|
|
171
|
+
<ProviderSetupGuide
|
|
172
|
+
guide={{ ...guide, sections: open ? [open] : [] }}
|
|
173
|
+
activeStage={stage}
|
|
174
|
+
actions={{
|
|
175
|
+
[CHECKOUT_CONFIRM_ACTION]: {
|
|
176
|
+
label: 'Já habilitei o Checkout Integrado',
|
|
177
|
+
run: onConfirm,
|
|
178
|
+
},
|
|
179
|
+
}}
|
|
180
|
+
beforeSections={
|
|
181
|
+
<>
|
|
182
|
+
{rows}
|
|
183
|
+
{settledSection ? <ConfirmedRow section={settledSection} onReopen={onReopen} /> : null}
|
|
184
|
+
</>
|
|
185
|
+
}
|
|
186
|
+
sectionFooter={sectionFooter}
|
|
187
|
+
/>
|
|
188
|
+
</Box>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "I have switched Checkout Integrado on" — the one setup fact no API answers.
|
|
5
|
+
*
|
|
6
|
+
* InfinitePay ships Checkout Integrado disabled and exposes nothing that
|
|
7
|
+
* reports its state: `payment_check` resolves the handle and stops there. So a
|
|
8
|
+
* store can pass "Testar conexão", read a green result, and still be unable to
|
|
9
|
+
* create a single payment link. The only way to know is to ask the owner, and
|
|
10
|
+
* the only honest thing to do with the answer is treat it as a claim — which
|
|
11
|
+
* step 3 then either confirms, by minting a link, or refutes, by being refused.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the browser keeps it
|
|
14
|
+
*
|
|
15
|
+
* It is not a fact about the merchant's account, it is a fact about what the
|
|
16
|
+
* owner has told us, so putting it in `payment_provider_configs` would dress a
|
|
17
|
+
* self-report up as provider state — and the schema already carries one field
|
|
18
|
+
* (`chargeVerifiedAt`) that means "proved by money moving". Two fields one
|
|
19
|
+
* letter apart in meaning is how a screen ends up claiming a store can charge.
|
|
20
|
+
*
|
|
21
|
+
* `localStorage` and not React state because the flow deliberately LEAVES the
|
|
22
|
+
* page: the owner pays on InfinitePay's site and comes back. In-memory, that
|
|
23
|
+
* round trip would drop the acknowledgement and bounce them to step 2 —
|
|
24
|
+
* hiding the panel that is polling the payment they just made.
|
|
25
|
+
*
|
|
26
|
+
* Keyed by the client's base URL, which is tenant-scoped
|
|
27
|
+
* (`/api/admin/<slug>/payments`), so an owner who administers two stores does
|
|
28
|
+
* not confirm the setting once and have it apply to the other.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function storageKey(scope: string, provider: string): string {
|
|
32
|
+
return `payments:checkout-confirmed:${scope}:${provider}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Cross-component notification — two panels must not disagree about this. */
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
function subscribe(listener: () => void): () => void {
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
// A confirmation made in another TAB is the same owner saying the same
|
|
41
|
+
// thing; nothing here should contradict it.
|
|
42
|
+
window.addEventListener('storage', listener);
|
|
43
|
+
return () => {
|
|
44
|
+
listeners.delete(listener);
|
|
45
|
+
window.removeEventListener('storage', listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every read and write is guarded: `localStorage` throws outright in a
|
|
51
|
+
* partitioned or storage-blocked context, and a setup screen must not go blank
|
|
52
|
+
* because a browser declined to remember a checkbox. Unavailable storage
|
|
53
|
+
* simply means "not confirmed yet" — the owner confirms again, which costs a
|
|
54
|
+
* click and nothing else.
|
|
55
|
+
*/
|
|
56
|
+
function read(key: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
return window.localStorage.getItem(key) === 'true';
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function write(key: string, value: boolean): void {
|
|
65
|
+
try {
|
|
66
|
+
if (value) window.localStorage.setItem(key, 'true');
|
|
67
|
+
else window.localStorage.removeItem(key);
|
|
68
|
+
} catch {
|
|
69
|
+
// Nothing to recover: the caller's UI already reflects the intent, and the
|
|
70
|
+
// worst case is being asked to confirm once more on the next visit.
|
|
71
|
+
}
|
|
72
|
+
listeners.forEach((listener) => listener());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface CheckoutAck {
|
|
76
|
+
/** The owner has said Checkout Integrado is on for this store. */
|
|
77
|
+
confirmed: boolean;
|
|
78
|
+
confirm: () => void;
|
|
79
|
+
/**
|
|
80
|
+
* Withdraw it. Called when the provider REFUSES to mint a link, which is the
|
|
81
|
+
* strongest available evidence that the setting is in fact off — leaving the
|
|
82
|
+
* claim standing there would keep the owner on a step 3 that cannot work.
|
|
83
|
+
*/
|
|
84
|
+
withdraw: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useCheckoutAck(scope: string, provider: string | null): CheckoutAck {
|
|
88
|
+
const key = provider ? storageKey(scope, provider) : null;
|
|
89
|
+
|
|
90
|
+
const confirmed = useSyncExternalStore(
|
|
91
|
+
subscribe,
|
|
92
|
+
() => (key ? read(key) : false),
|
|
93
|
+
// Server render: nothing is confirmed, which is the state that shows the
|
|
94
|
+
// step rather than the state that skips it.
|
|
95
|
+
() => false,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const confirm = useCallback(() => {
|
|
99
|
+
if (key) write(key, true);
|
|
100
|
+
}, [key]);
|
|
101
|
+
const withdraw = useCallback(() => {
|
|
102
|
+
if (key) write(key, false);
|
|
103
|
+
}, [key]);
|
|
104
|
+
|
|
105
|
+
return { confirmed, confirm, withdraw };
|
|
106
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { MaskedProviderConfig } from '@12-apps/payments-backend';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Is there actually a connection here?
|
|
5
|
+
*
|
|
6
|
+
* The one definition, because the screen shipped with two. Disconnecting does
|
|
7
|
+
* NOT delete the row — it empties every environment's credentials and resets
|
|
8
|
+
* the status — so `config !== null` keeps answering "yes" for a store that has
|
|
9
|
+
* nothing left to charge with. The provider list said `Conectado` while the
|
|
10
|
+
* panel one click away offered `Conectar com PagBank`.
|
|
11
|
+
*
|
|
12
|
+
* `RECONNECT_REQUIRED` counts as connected on purpose: that store demonstrably
|
|
13
|
+
* worked and still holds credentials, it is the AUTHORIZATION that lapsed.
|
|
14
|
+
*/
|
|
15
|
+
export function isConnected(config: MaskedProviderConfig | null | undefined): boolean {
|
|
16
|
+
const status = config?.status;
|
|
17
|
+
return status === 'VERIFIED' || status === 'RECONNECT_REQUIRED';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is there enough here to attempt the activation charge?
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT {@link isConnected}. That reads `status`, which only the
|
|
24
|
+
* credential PROBE sets — so a store that had pasted perfectly good keys saw no
|
|
25
|
+
* activation step at all until it ran "Testar conexão" first, and for a
|
|
26
|
+
* key-based provider that probe answers a question nobody asked. The charge is
|
|
27
|
+
* the real test; making it wait behind a weaker one is backwards.
|
|
28
|
+
*
|
|
29
|
+
* Credentials in the ACTIVE environment are the bar, plus `isConnected` for the
|
|
30
|
+
* OAuth case, where the grant is the credential and no field is ever filled in.
|
|
31
|
+
*/
|
|
32
|
+
export function canAttemptCharge(config: MaskedProviderConfig | null | undefined): boolean {
|
|
33
|
+
if (!config) return false;
|
|
34
|
+
if (isConnected(config)) return true;
|
|
35
|
+
// Credentials alone are NOT enough for a provider whose activation charge is
|
|
36
|
+
// a real payment. Offering "Pagar R$1,01 e ativar" beside an UNVERIFIED tag
|
|
37
|
+
// invites an owner to send real money through a handle nothing has checked —
|
|
38
|
+
// and a mistyped InfiniteTag pays a stranger, irreversibly. It also put the
|
|
39
|
+
// step-3 card on screen while the walkthrough was still saying "step 2: go
|
|
40
|
+
// enable Checkout", which is two answers to "what do I do now".
|
|
41
|
+
//
|
|
42
|
+
// The probe is one click and costs nothing, so requiring it first takes away
|
|
43
|
+
// no capability — it just orders the two steps the way the guide already
|
|
44
|
+
// describes them.
|
|
45
|
+
if (config.chargeVerifiedAt) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a provider card reports at a glance. */
|
|
50
|
+
type ConnectionBadge =
|
|
51
|
+
| { label: 'Ativo'; color: 'success' }
|
|
52
|
+
| { label: 'Conectado'; color: 'default' }
|
|
53
|
+
| { label: 'Reconectar'; color: 'error' }
|
|
54
|
+
| { label: 'Não conectado'; color: 'warning' };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `enabled` is deliberately checked first: it is the only state that means
|
|
58
|
+
* money can move, and a store reading `Ativo` has already passed everything
|
|
59
|
+
* below it.
|
|
60
|
+
*/
|
|
61
|
+
export function connectionBadge(config: MaskedProviderConfig | null): ConnectionBadge {
|
|
62
|
+
if (config?.enabled) return { label: 'Ativo', color: 'success' };
|
|
63
|
+
if (config?.status === 'RECONNECT_REQUIRED') return { label: 'Reconectar', color: 'error' };
|
|
64
|
+
if (isConnected(config)) return { label: 'Conectado', color: 'default' };
|
|
65
|
+
return { label: 'Não conectado', color: 'warning' };
|
|
66
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CredentialFieldSpec,
|
|
3
|
+
MaskedProviderConfig,
|
|
4
|
+
PaymentEnvironment,
|
|
5
|
+
ProviderDescriptor,
|
|
6
|
+
} from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import type { PendingSave } from './ConfirmCredentialSave';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The DECISIONS the credential form makes, with no JSX anywhere near them.
|
|
12
|
+
*
|
|
13
|
+
* Each one is a rule about money rather than about layout — is there anything
|
|
14
|
+
* stored to probe, could this value possibly be a real handle, is it changing
|
|
15
|
+
* at all, what does the button commit — and every one of them was learned from
|
|
16
|
+
* a screen that got it wrong. Kept together, and apart from the rendering, so
|
|
17
|
+
* they can be read as the set of rules they are.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is there actually something stored to probe?
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export function allRequiredStored(
|
|
28
|
+
descriptor: ProviderDescriptor,
|
|
29
|
+
config: MaskedProviderConfig,
|
|
30
|
+
environment: PaymentEnvironment,
|
|
31
|
+
): boolean {
|
|
32
|
+
const stored = config.environments[environment] ?? {};
|
|
33
|
+
return descriptor.credentialSchema
|
|
34
|
+
.filter((spec) => spec.required)
|
|
35
|
+
.every((spec) => stored[spec.key]?.configured === true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does every edited field match the SHAPE its provider declares?
|
|
40
|
+
*
|
|
41
|
+
* Shape only — it cannot know whether an account exists, which is what the
|
|
42
|
+
* probe immediately after the save is for. But `$` followed by a few safe
|
|
43
|
+
* characters is free to check, and it stops the ordinary slips (an e-mail, an
|
|
44
|
+
* `@handle` with no `$`) reaching a confirmation dialog that would then ask the
|
|
45
|
+
* owner to vouch for them.
|
|
46
|
+
*/
|
|
47
|
+
export function fieldsWellFormed(
|
|
48
|
+
descriptor: ProviderDescriptor,
|
|
49
|
+
values: Record<string, string>,
|
|
50
|
+
): boolean {
|
|
51
|
+
return descriptor.credentialSchema.every((spec) => {
|
|
52
|
+
const value = values[spec.key];
|
|
53
|
+
if (!spec.pattern || value === undefined || value === '') return true;
|
|
54
|
+
return new RegExp(spec.pattern).test(value.trim());
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveLabel(descriptor: ProviderDescriptor): string {
|
|
59
|
+
const required = descriptor.credentialSchema.filter((field) => field.required);
|
|
60
|
+
const only = required.length === 1 ? required[0] : undefined;
|
|
61
|
+
if (!only) return 'Salvar';
|
|
62
|
+
return `Salvar ${only.label.replace(/\s*\([^)]*\)\s*$/, '')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The stored value of the field a finished step collapses around.
|
|
67
|
+
*
|
|
68
|
+
* Non-secret fields keep their value in `hint` (secrets never leave the
|
|
69
|
+
* server), so a provider whose one credential is a public handle can show it
|
|
70
|
+
* back verbatim — which is the entire content of the summary row.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately narrow: EXACTLY ONE required field, and it must be readable
|
|
73
|
+
* back. A form of four fields does not have a one-line summary, and collapsing
|
|
74
|
+
* it would hide inputs the owner still has to reach — so those providers keep
|
|
75
|
+
* the form they have always had.
|
|
76
|
+
*/
|
|
77
|
+
export function summaryOf(
|
|
78
|
+
descriptor: ProviderDescriptor,
|
|
79
|
+
config: MaskedProviderConfig | null,
|
|
80
|
+
environment: PaymentEnvironment,
|
|
81
|
+
): { spec: CredentialFieldSpec; value: string } | null {
|
|
82
|
+
const required = descriptor.credentialSchema.filter((field) => field.required);
|
|
83
|
+
const spec = required.length === 1 ? required[0] : undefined;
|
|
84
|
+
if (!spec || spec.secret) return null;
|
|
85
|
+
const state = config?.environments[environment]?.[spec.key];
|
|
86
|
+
if (!state?.configured || !state.hint) return null;
|
|
87
|
+
return { spec, value: state.hint };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which field, if any, needs reading back before the write.
|
|
92
|
+
*
|
|
93
|
+
* Only when it is actually CHANGING: `confirmOnSave` guards against a wrong
|
|
94
|
+
* value being stored, and re-saving the value already stored cannot introduce
|
|
95
|
+
* one. Confirming a no-op would train the owner to click through the dialog,
|
|
96
|
+
* which is the one way to make it useless.
|
|
97
|
+
*/
|
|
98
|
+
export function needsConfirmation(
|
|
99
|
+
descriptor: ProviderDescriptor,
|
|
100
|
+
config: MaskedProviderConfig | null,
|
|
101
|
+
environment: PaymentEnvironment,
|
|
102
|
+
values: Record<string, string>,
|
|
103
|
+
): PendingSave | null {
|
|
104
|
+
for (const spec of descriptor.credentialSchema) {
|
|
105
|
+
if (!spec.confirmOnSave) continue;
|
|
106
|
+
const next = values[spec.key];
|
|
107
|
+
if (next === undefined || next === '') continue;
|
|
108
|
+
if (next === config?.environments[environment]?.[spec.key]?.hint) continue;
|
|
109
|
+
return { spec, value: next };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Everything the form remembers, and the two writes it can make.
|
|
116
|
+
*
|
|
117
|
+
* Hoisted out of the component so that stays about LAYOUT — which of the two
|
|
118
|
+
* shapes (fields or summary row) is on screen — while this owns the rules that
|
|
119
|
+
* decide it. Splitting the two is also what keeps each inside the size gate.
|
|
120
|
+
*/
|