@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,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import type { VerifiedProviderConfig } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
import { ENVIRONMENT_LABELS } from './EnvironmentTabs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The credential step's alerts and its one button.
|
|
11
|
+
*
|
|
12
|
+
* Split from `ProviderCredentialForm` because that file is about the FORM's
|
|
13
|
+
* state machine — what is typed, what is stored, what the probe last said —
|
|
14
|
+
* while these three are about the sentences the owner reads when it goes
|
|
15
|
+
* wrong, and each of those sentences was learned from a screen that got it
|
|
16
|
+
* wrong in a way that cost somebody money.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Named for what it is here, not `ProbeResult` — the payments package already
|
|
20
|
+
// exports a different `ProbeResult` (failover classification).
|
|
21
|
+
export type VerifyProbe = VerifiedProviderConfig['probe'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the probe found — only when it found something WRONG.
|
|
25
|
+
*
|
|
26
|
+
* A passing probe already has a place to be said: the `CONEXÃO OK` chip in the
|
|
27
|
+
* header, which persists. Repeating it as a green banner mid-flow added a
|
|
28
|
+
* fourth block to a screen whose whole point is showing one step at a time,
|
|
29
|
+
* and said nothing the chip had not. A failure is different: the chip can only
|
|
30
|
+
* report that something is wrong, not which environment or what to do, so that
|
|
31
|
+
* one still gets its sentence.
|
|
32
|
+
*/
|
|
33
|
+
export function ProbeAlert({
|
|
34
|
+
probe,
|
|
35
|
+
busy,
|
|
36
|
+
onRetry,
|
|
37
|
+
}: {
|
|
38
|
+
probe: VerifyProbe;
|
|
39
|
+
busy: boolean;
|
|
40
|
+
onRetry: () => void;
|
|
41
|
+
}) {
|
|
42
|
+
if (probe.ok) return null;
|
|
43
|
+
const where = ENVIRONMENT_LABELS[probe.environment];
|
|
44
|
+
// The ADAPTER's sentence when it has one. Only it knows the provider's name,
|
|
45
|
+
// what that provider's own app calls this field and which screen states it —
|
|
46
|
+
// "confira as credenciais deste ambiente" is advice you can follow all
|
|
47
|
+
// afternoon without ever finding the value you were asked to check.
|
|
48
|
+
const message =
|
|
49
|
+
probe.message ??
|
|
50
|
+
`Não foi possível conectar em ${where}. Confira as credenciais deste ambiente.`;
|
|
51
|
+
|
|
52
|
+
// An outage is not a rejection. The credential is already SAVED and may well
|
|
53
|
+
// be perfect; nothing was learned about it. So it is amber rather than red,
|
|
54
|
+
// and it carries the only control that helps — ask again. Without one the
|
|
55
|
+
// owner's sole route back was to re-save a value that was never wrong, and
|
|
56
|
+
// saving is what resets the connection.
|
|
57
|
+
const unreachable = probe.fault === 'UNREACHABLE';
|
|
58
|
+
return (
|
|
59
|
+
<Alert
|
|
60
|
+
severity={unreachable ? 'warning' : 'error'}
|
|
61
|
+
data-testid="payments-probe-result"
|
|
62
|
+
action={
|
|
63
|
+
unreachable ? (
|
|
64
|
+
<Button
|
|
65
|
+
size="small"
|
|
66
|
+
disabled={busy}
|
|
67
|
+
onClick={onRetry}
|
|
68
|
+
data-testid="payments-verify-retry"
|
|
69
|
+
sx={{ textTransform: 'none' }}
|
|
70
|
+
>
|
|
71
|
+
Testar conexão
|
|
72
|
+
</Button>
|
|
73
|
+
) : undefined
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
{message}
|
|
77
|
+
</Alert>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Changing the tag on a store that has ALREADY proved it can receive.
|
|
83
|
+
*
|
|
84
|
+
* The dialog two steps later asks "is this the right account"; this answers a
|
|
85
|
+
* different question the owner has not thought to ask — what happens to the
|
|
86
|
+
* store while they do it. A save invalidates the proof and drops the provider
|
|
87
|
+
* out of the failover chain (`applySaveCredentials`), so the shop stops taking
|
|
88
|
+
* money through it until a new activation charge lands. That is entirely
|
|
89
|
+
* correct and entirely invisible, and an owner who came to fix a typo deserves
|
|
90
|
+
* to learn it before typing rather than from a quiet storefront.
|
|
91
|
+
*/
|
|
92
|
+
export function ReverifyWarning() {
|
|
93
|
+
return (
|
|
94
|
+
<Alert severity="warning" data-testid="payments-reverify-warning">
|
|
95
|
+
Esta loja já está verificada. Trocar a InfiniteTag <strong>exige uma nova verificação</strong>{' '}
|
|
96
|
+
e a loja <strong>para de receber</strong> por este provedor até que ela termine.
|
|
97
|
+
</Alert>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Salvar + Testar conexão, each dead when it would do nothing (or harm). */
|
|
102
|
+
/**
|
|
103
|
+
* One button. Saving IS testing.
|
|
104
|
+
*
|
|
105
|
+
* "Testar conexão" was a second button the owner had to know to press, guarding
|
|
106
|
+
* a step they could not finish without it — and its enablement rule was the
|
|
107
|
+
* inverse of Salvar's (dead while the form was dirty, live only once it was
|
|
108
|
+
* clean), so the two took turns being grey for reasons no label explained.
|
|
109
|
+
* Nothing is learned by separating them: the probe reads what was just stored,
|
|
110
|
+
* so the only useful moment to run it is immediately after a save.
|
|
111
|
+
*/
|
|
112
|
+
export function FormActions({
|
|
113
|
+
busy,
|
|
114
|
+
label,
|
|
115
|
+
disabled,
|
|
116
|
+
onSave,
|
|
117
|
+
}: {
|
|
118
|
+
busy: string | null;
|
|
119
|
+
label: string;
|
|
120
|
+
disabled: boolean;
|
|
121
|
+
onSave: () => void;
|
|
122
|
+
}) {
|
|
123
|
+
return (
|
|
124
|
+
<Stack spacing={1} alignItems="flex-start">
|
|
125
|
+
<Button
|
|
126
|
+
variant="contained"
|
|
127
|
+
data-testid="payments-save"
|
|
128
|
+
disabled={busy !== null || disabled}
|
|
129
|
+
sx={{ textTransform: 'none' }}
|
|
130
|
+
onClick={onSave}
|
|
131
|
+
>
|
|
132
|
+
{busy === 'save' ? <CircularProgress size={18} /> : label}
|
|
133
|
+
</Button>
|
|
134
|
+
{busy === 'verify' ? (
|
|
135
|
+
<Stack direction="row" spacing={1} alignItems="center" data-testid="payments-verifying">
|
|
136
|
+
<CircularProgress size={14} />
|
|
137
|
+
<Typography variant="body2" color="text.secondary">
|
|
138
|
+
Testando a conexão…
|
|
139
|
+
</Typography>
|
|
140
|
+
</Stack>
|
|
141
|
+
) : null}
|
|
142
|
+
</Stack>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Tab, Tabs } from '@mui/material';
|
|
4
|
+
|
|
5
|
+
import type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
6
|
+
|
|
7
|
+
/** One environment's human name — the tabs and the probe result share it. */
|
|
8
|
+
export const ENVIRONMENT_LABELS: Record<PaymentEnvironment, string> = {
|
|
9
|
+
SANDBOX: 'Sandbox',
|
|
10
|
+
PRODUCTION: 'Produção',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sentence case, because these are place names rather than shouted commands —
|
|
15
|
+
* MUI upper-cases tab labels by default, which turns "Produção" into signage.
|
|
16
|
+
*/
|
|
17
|
+
const TAB_SX = { textTransform: 'none' } as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The two environments as tabs, not a select: there are exactly two, and which
|
|
21
|
+
* one you are editing changes what every field below it means. A closed
|
|
22
|
+
* dropdown hides that, and credentials are stored PER environment — so pasting
|
|
23
|
+
* a production token while "Sandbox" sits collapsed above is a mistake the
|
|
24
|
+
* control itself should not allow.
|
|
25
|
+
*/
|
|
26
|
+
export function EnvironmentSelector({
|
|
27
|
+
environment,
|
|
28
|
+
onChange,
|
|
29
|
+
}: {
|
|
30
|
+
environment: PaymentEnvironment;
|
|
31
|
+
onChange: (next: PaymentEnvironment) => void;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<Tabs
|
|
35
|
+
value={environment}
|
|
36
|
+
onChange={(_, next: PaymentEnvironment) => onChange(next)}
|
|
37
|
+
aria-label="Ambiente"
|
|
38
|
+
data-testid="payments-environment-tabs"
|
|
39
|
+
>
|
|
40
|
+
<Tab
|
|
41
|
+
label="Sandbox"
|
|
42
|
+
value="SANDBOX"
|
|
43
|
+
data-testid="payments-environment-SANDBOX"
|
|
44
|
+
sx={TAB_SX}
|
|
45
|
+
/>
|
|
46
|
+
<Tab
|
|
47
|
+
label="Produção"
|
|
48
|
+
value="PRODUCTION"
|
|
49
|
+
data-testid="payments-environment-PRODUCTION"
|
|
50
|
+
sx={TAB_SX}
|
|
51
|
+
/>
|
|
52
|
+
</Tabs>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A banner that makes Produção LOOK different from Sandbox.
|
|
58
|
+
*
|
|
59
|
+
* Two tabs of identical fields, differing by one word in a tab label, is the
|
|
60
|
+
* whole of what distinguished "type here and nothing happens" from "type here
|
|
61
|
+
* and this is what charges your customers". The tab you are on is the least
|
|
62
|
+
* noticeable thing on the screen, and the consequences of being on the wrong
|
|
63
|
+
* one are entirely invisible until money moves.
|
|
64
|
+
*
|
|
65
|
+
* It states only what this package can actually back. NOT "nothing is real in
|
|
66
|
+
* Sandbox": whether a sandbox call is faked depends on the deployment, and
|
|
67
|
+
* InfinitePay has no sandbox at all — a reassurance that is sometimes false is
|
|
68
|
+
* worse than none. What is always true is that the two credential sets are
|
|
69
|
+
* separate, and which one serves the store's checkout is `config.environment`
|
|
70
|
+
* — so when you are looking at the other one, the banner says so.
|
|
71
|
+
*/
|
|
72
|
+
export function EnvironmentNotice({
|
|
73
|
+
environment,
|
|
74
|
+
active,
|
|
75
|
+
}: {
|
|
76
|
+
environment: PaymentEnvironment;
|
|
77
|
+
/** The environment this store's real checkout uses, when it has one. */
|
|
78
|
+
active: PaymentEnvironment | null;
|
|
79
|
+
}) {
|
|
80
|
+
const elsewhere = active !== null && active !== environment ? ENVIRONMENT_LABELS[active] : null;
|
|
81
|
+
const production = environment === 'PRODUCTION';
|
|
82
|
+
return (
|
|
83
|
+
<Alert
|
|
84
|
+
severity={production ? 'warning' : 'info'}
|
|
85
|
+
variant="standard"
|
|
86
|
+
square
|
|
87
|
+
data-testid={`payments-environment-notice-${environment}`}
|
|
88
|
+
sx={{ borderRadius: 0, py: 0.5, px: 3 }}
|
|
89
|
+
>
|
|
90
|
+
<strong>
|
|
91
|
+
{production ? 'Produção — dinheiro real.' : 'Sandbox — ambiente de teste.'}
|
|
92
|
+
</strong>{' '}
|
|
93
|
+
{production
|
|
94
|
+
? 'Tudo o que você fizer aqui vale para as vendas da loja.'
|
|
95
|
+
: 'Nenhum valor sai ou entra de verdade.'}
|
|
96
|
+
{elsewhere ? ` Hoje a loja está usando ${elsewhere}.` : ''}
|
|
97
|
+
</Alert>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The banner is deliberately NOT bundled with the tabs any more.
|
|
103
|
+
*
|
|
104
|
+
* It has to run edge to edge across the provider card — a full-bleed strip is
|
|
105
|
+
* what makes it read as a property of everything below it, rather than as one
|
|
106
|
+
* more paragraph indented inside the content. That is only possible if the card
|
|
107
|
+
* has a padded header and an unpadded band between it and the body, so the two
|
|
108
|
+
* halves are placed separately by `ActivePanel`.
|
|
109
|
+
*/
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Alert, Box, Button, CircularProgress } from '@mui/material';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { MaskedProviderConfig } from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import type { PaymentsSettingsClient } from '../client';
|
|
9
|
+
import { canAttemptCharge } from './connection-state';
|
|
10
|
+
import { ProviderList } from './ProviderList';
|
|
11
|
+
import { ActivePanel, type ActivePanelProps, type PrepareConnect } from './ProviderPanel';
|
|
12
|
+
import {
|
|
13
|
+
guideAwaitsConfirmation,
|
|
14
|
+
progressKeyOf,
|
|
15
|
+
useOpenProvider,
|
|
16
|
+
useSelectedProvider,
|
|
17
|
+
useSettingsState,
|
|
18
|
+
useSetupConfirmation,
|
|
19
|
+
useSetupGuide,
|
|
20
|
+
} from './settings-state';
|
|
21
|
+
import { openSection, SetupGuideSection } from './SetupGuideSection';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Plug-and-play settings page for payment providers — the reusable
|
|
25
|
+
* equivalent of the app's per-provider config screen:
|
|
26
|
+
*
|
|
27
|
+
* - the FAILOVER CHAIN: the enabled providers in the order checkout will
|
|
28
|
+
* try them, reorderable by drag or by keyboard
|
|
29
|
+
* - provider selector (every adapter registered in the backend)
|
|
30
|
+
* - per provider, EITHER a connect button (`authMode: 'oauth'`) or a
|
|
31
|
+
* schema-driven credential form — and for OAuth providers, both
|
|
32
|
+
* - SANDBOX/PRODUCTION environment switch (both credential sets kept)
|
|
33
|
+
* - save / verify ("test connection") / enable (join or leave the chain)
|
|
34
|
+
* - the provider's own onboarding walkthrough
|
|
35
|
+
*
|
|
36
|
+
* The host provides only the authenticated `client` and (optionally) the
|
|
37
|
+
* connect-state minting for OAuth providers.
|
|
38
|
+
*/
|
|
39
|
+
export interface PaymentProviderSettingsProps {
|
|
40
|
+
client: PaymentsSettingsClient;
|
|
41
|
+
/** Called after any state-changing action, e.g. to refresh host UI. */
|
|
42
|
+
onChanged?: (config: MaskedProviderConfig) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Required to render `authMode: 'oauth'` providers: the host mints and
|
|
45
|
+
* persists the CSRF state against the admin session and returns it with
|
|
46
|
+
* the callback URL. Omit it and OAuth providers fall back to their
|
|
47
|
+
* credential form instead of showing a connect button that cannot work.
|
|
48
|
+
*/
|
|
49
|
+
prepareConnect?: PrepareConnect;
|
|
50
|
+
/**
|
|
51
|
+
* Provider to open on mount instead of the list.
|
|
52
|
+
*
|
|
53
|
+
* The OAuth callback comes back to this page having just connected one — and
|
|
54
|
+
* landing on the provider LIST at that moment hides the very thing that
|
|
55
|
+
* changed, along with the switch that still has to be flipped. The host knows
|
|
56
|
+
* which provider it was (its own callback said so); the package does not read
|
|
57
|
+
* the URL.
|
|
58
|
+
*/
|
|
59
|
+
initialProvider?: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Which provider is open, when the HOST owns that state (e.g. it lives in
|
|
62
|
+
* the URL, so each provider is its own linkable page).
|
|
63
|
+
*
|
|
64
|
+
* Passing it — `null` included — switches this component to controlled mode:
|
|
65
|
+
* it stops keeping its own selection and `initialProvider` no longer applies,
|
|
66
|
+
* because the host's value is now the single source of truth. Leave it
|
|
67
|
+
* `undefined` and nothing changes: selection stays internal, as every
|
|
68
|
+
* existing caller expects.
|
|
69
|
+
*/
|
|
70
|
+
selectedProvider?: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Fired when the owner picks a provider (or leaves one, with `null`).
|
|
73
|
+
*
|
|
74
|
+
* Independent of `selectedProvider`: an uncontrolled host can use this purely
|
|
75
|
+
* to observe, while a controlled host uses it to write the new selection
|
|
76
|
+
* wherever it keeps it.
|
|
77
|
+
*/
|
|
78
|
+
onProviderChange?: (provider: string | null) => void;
|
|
79
|
+
/**
|
|
80
|
+
* The activation step, rendered under the connection card.
|
|
81
|
+
*
|
|
82
|
+
* "Connected" and "can charge" are different facts, and only the HOST can
|
|
83
|
+
* prove the second one: it owns the endpoint that puts a real R$0,01 through
|
|
84
|
+
* the store's own account and enables the provider when it lands (FUT-463).
|
|
85
|
+
* The package decides only WHERE that step appears, never how it works — the
|
|
86
|
+
* same split as `prepareConnect`.
|
|
87
|
+
*
|
|
88
|
+
* `onVerified` refreshes this screen, so a passing charge immediately shows
|
|
89
|
+
* the provider as active.
|
|
90
|
+
*/
|
|
91
|
+
renderVerification?: (context: {
|
|
92
|
+
provider: string;
|
|
93
|
+
/**
|
|
94
|
+
* The provider's human name ("InfinitePay").
|
|
95
|
+
*
|
|
96
|
+
* The activation step tells the owner who refused what, and "o provedor
|
|
97
|
+
* recusou criar a cobrança" is the same sentence with the one word that
|
|
98
|
+
* makes it actionable removed — an owner reading it on a screen that lists
|
|
99
|
+
* three providers has to work out which one is being talked about.
|
|
100
|
+
*/
|
|
101
|
+
displayName: string;
|
|
102
|
+
/** A stored connection exists — there is something to charge through. */
|
|
103
|
+
connected: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* A real charge through this connection has already succeeded
|
|
106
|
+
* (`chargeVerifiedAt` is stamped). The step renders a confirmation instead
|
|
107
|
+
* of demanding another payment: an owner who has ALREADY paid the R$0,01
|
|
108
|
+
* and reloads must be told it worked, not shown the pay button again — the
|
|
109
|
+
* button that being shown again is what made one owner pay four times.
|
|
110
|
+
*/
|
|
111
|
+
proven: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* An earlier step is still outstanding, so a NEW charge must not be
|
|
114
|
+
* offered yet — the owner has not confirmed the provider-side switch
|
|
115
|
+
* without which no link can be minted at all.
|
|
116
|
+
*
|
|
117
|
+
* It withholds the pay BUTTON and nothing else. The step itself must go on
|
|
118
|
+
* rendering whatever its state: it is what resumes an outstanding charge on
|
|
119
|
+
* mount and what reads the `transaction_nsu` a returning payer arrives
|
|
120
|
+
* with, so not rendering it is how a payment that HAS been made stops being
|
|
121
|
+
* confirmable. A blocked screen may cost a click; an unmounted one costs
|
|
122
|
+
* the money.
|
|
123
|
+
*/
|
|
124
|
+
blocked: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* The walkthrough has an EARLIER step open, so this one is not the current
|
|
127
|
+
* step. It must still render whatever it has already settled.
|
|
128
|
+
*
|
|
129
|
+
* The same lesson as `blocked`, one turn further. Unmounting was how the
|
|
130
|
+
* one sentence explaining why step 2 had just reopened disappeared in the
|
|
131
|
+
* same frame that reopened it: the provider refuses to mint a link, the
|
|
132
|
+
* step-2 confirmation is withdrawn on that evidence, the guide goes back a
|
|
133
|
+
* step — and the panel carrying "the provider refused, here is why" is
|
|
134
|
+
* taken off screen by its own report. The owner is returned to a step they
|
|
135
|
+
* believed they had done, with nothing saying so.
|
|
136
|
+
*/
|
|
137
|
+
hidden: boolean;
|
|
138
|
+
onVerified: () => void;
|
|
139
|
+
/**
|
|
140
|
+
* The provider refused to create the charge — the strongest evidence
|
|
141
|
+
* available that the step the owner ticked off is not in fact done. Undoes
|
|
142
|
+
* the confirmation and puts them back on it.
|
|
143
|
+
*/
|
|
144
|
+
onSetupIncomplete: () => void;
|
|
145
|
+
}) => ReactNode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Everything the activation step needs that only this screen knows. */
|
|
149
|
+
interface VerificationInputs {
|
|
150
|
+
render: PaymentProviderSettingsProps['renderVerification'];
|
|
151
|
+
provider: string;
|
|
152
|
+
displayName: string;
|
|
153
|
+
config: MaskedProviderConfig | null;
|
|
154
|
+
/** An earlier step is unfinished: withhold the pay button, render the rest. */
|
|
155
|
+
blocked: boolean;
|
|
156
|
+
/** The walkthrough is on an earlier step; this one is not on screen at all. */
|
|
157
|
+
hidden: boolean;
|
|
158
|
+
onVerified: () => void;
|
|
159
|
+
onSetupIncomplete: () => void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The activation step's context, built from what this screen already knows. */
|
|
163
|
+
function verificationFor(io: VerificationInputs): ReactNode {
|
|
164
|
+
// `enabled` is deliberately NOT passed: the step used it to show a standing
|
|
165
|
+
// "provider is active" banner, which kept reassuring a store whose every
|
|
166
|
+
// charge was being refused. `proven` is a different kind of fact — it is
|
|
167
|
+
// stamped only by a real charge landing, which is exactly what this step
|
|
168
|
+
// exists to observe.
|
|
169
|
+
return io.render?.({
|
|
170
|
+
provider: io.provider,
|
|
171
|
+
displayName: io.displayName,
|
|
172
|
+
connected: canAttemptCharge(io.config),
|
|
173
|
+
proven: Boolean(io.config?.chargeVerifiedAt),
|
|
174
|
+
blocked: io.blocked,
|
|
175
|
+
// Never `hidden` once the charge has landed: then this step IS the
|
|
176
|
+
// confirmation, and the guide has nothing left to show anyway.
|
|
177
|
+
hidden: io.hidden && !io.config?.chargeVerifiedAt,
|
|
178
|
+
onVerified: io.onVerified,
|
|
179
|
+
onSetupIncomplete: io.onSetupIncomplete,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ProviderScreenProps extends ActivePanelProps {
|
|
184
|
+
onBack: () => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One provider's configuration, with the way back to the list. */
|
|
188
|
+
function ProviderScreen({ onBack, ...panel }: ProviderScreenProps) {
|
|
189
|
+
return (
|
|
190
|
+
<Box data-testid="payments-provider-settings">
|
|
191
|
+
<Button
|
|
192
|
+
size="small"
|
|
193
|
+
onClick={onBack}
|
|
194
|
+
data-testid="payments-provider-back"
|
|
195
|
+
sx={{ mb: 2, textTransform: 'none' }}
|
|
196
|
+
>
|
|
197
|
+
← Voltar aos provedores
|
|
198
|
+
</Button>
|
|
199
|
+
<ActivePanel {...panel} />
|
|
200
|
+
</Box>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function PaymentProviderSettings({
|
|
205
|
+
client,
|
|
206
|
+
onChanged,
|
|
207
|
+
prepareConnect,
|
|
208
|
+
initialProvider = null,
|
|
209
|
+
selectedProvider,
|
|
210
|
+
onProviderChange,
|
|
211
|
+
renderVerification,
|
|
212
|
+
}: PaymentProviderSettingsProps) {
|
|
213
|
+
const { view, error, reload } = useSettingsState(client);
|
|
214
|
+
const { selected, setSelected } = useSelectedProvider(
|
|
215
|
+
initialProvider,
|
|
216
|
+
selectedProvider,
|
|
217
|
+
onProviderChange,
|
|
218
|
+
);
|
|
219
|
+
// Resolved above the early returns, because the guide hook depends on both.
|
|
220
|
+
const { active, activeConfig } = useOpenProvider(view, selected);
|
|
221
|
+
const { guide, loaded } = useSetupGuide(client, active?.name ?? null, progressKeyOf(activeConfig));
|
|
222
|
+
const ack = useSetupConfirmation(client, active, activeConfig);
|
|
223
|
+
|
|
224
|
+
if (error) return <Alert severity="error">{error}</Alert>;
|
|
225
|
+
if (!view) return <CircularProgress data-testid="payments-settings-loading" />;
|
|
226
|
+
|
|
227
|
+
if (!active) {
|
|
228
|
+
return <ProviderList view={view} client={client} reload={reload} onSelect={setSelected} />;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<ProviderScreen
|
|
233
|
+
descriptor={active}
|
|
234
|
+
config={activeConfig}
|
|
235
|
+
client={client}
|
|
236
|
+
onChanged={onChanged}
|
|
237
|
+
reload={() => void reload()}
|
|
238
|
+
prepareConnect={prepareConnect}
|
|
239
|
+
onBack={() => setSelected(null)}
|
|
240
|
+
// A new credential may name a DIFFERENT InfinitePay account, and the
|
|
241
|
+
// owner's "Checkout Integrado is on" was said about the old one. The
|
|
242
|
+
// server already drops its own verdict and its proof on any credential
|
|
243
|
+
// change; this drops the half it cannot see.
|
|
244
|
+
onCredentialsReplaced={ack.withdraw}
|
|
245
|
+
verification={verificationFor({
|
|
246
|
+
render: renderVerification,
|
|
247
|
+
provider: active.name,
|
|
248
|
+
displayName: active.displayName,
|
|
249
|
+
config: activeConfig,
|
|
250
|
+
blocked: guideAwaitsConfirmation(guide, ack.confirmed, loaded),
|
|
251
|
+
// The walkthrough still has a step to show ⇒ this is not that step.
|
|
252
|
+
hidden: !loaded || openSection(guide, ack.confirmed, false) !== null,
|
|
253
|
+
onVerified: () => void reload(),
|
|
254
|
+
onSetupIncomplete: ack.withdraw,
|
|
255
|
+
})}
|
|
256
|
+
guide={(slots) => (
|
|
257
|
+
<SetupGuideSection
|
|
258
|
+
guide={guide}
|
|
259
|
+
confirmed={ack.confirmed}
|
|
260
|
+
onConfirm={ack.confirm}
|
|
261
|
+
onReopen={ack.withdraw}
|
|
262
|
+
{...slots}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
/>
|
|
266
|
+
);
|
|
267
|
+
}
|