@12-apps/payments-frontend 3.29.0 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/CredentialFieldStack.tsx +2 -0
- package/src/components/CredentialFields.tsx +5 -3
- package/src/components/CredentialFormAlerts.tsx +11 -6
- package/src/components/ProviderPanel.tsx +41 -9
- package/src/components/ProviderStatusBar.tsx +5 -2
- package/src/components/SetupGuideSection.tsx +15 -2
- package/src/components/checkout/live-context.tsx +9 -6
- package/src/components/checkout/poll-delay.ts +54 -0
- package/src/components/checkout/poll-hint.ts +58 -0
- package/src/components/checkout/poll-loop.ts +31 -52
- package/src/components/checks-locally.ts +13 -0
- package/src/components/credential-rules.ts +8 -2
- package/src/components/settings-copy.ts +19 -0
- package/src/components/settings-en-US.ts +6 -0
- package/src/components/settings-pt-BR.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"storybook:build": "storybook build"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@12-apps/payments-backend": "^4.
|
|
26
|
+
"@12-apps/payments-backend": "^4.30.0",
|
|
27
27
|
"react-qr-code": "^2.2.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
@@ -6,6 +6,7 @@ import type { PaymentEnvironment, ProviderDescriptor } from '@12-apps/payments-b
|
|
|
6
6
|
|
|
7
7
|
import { CredentialField } from './CredentialFields';
|
|
8
8
|
import { appliesIn, saveLabel } from './credential-rules';
|
|
9
|
+
import { checksLocally } from './checks-locally';
|
|
9
10
|
import { FormActions, ReverifyWarning } from './CredentialFormAlerts';
|
|
10
11
|
import type { CredentialFormState } from './ProviderCredentialForm';
|
|
11
12
|
import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
@@ -58,6 +59,7 @@ export function CredentialFields({
|
|
|
58
59
|
willTest={form.complete}
|
|
59
60
|
disabled={form.nothingEdited || !form.valid}
|
|
60
61
|
onSave={form.requestSave}
|
|
62
|
+
local={checksLocally(descriptor)}
|
|
61
63
|
/>
|
|
62
64
|
</Stack>
|
|
63
65
|
);
|
|
@@ -116,9 +116,11 @@ export function CredentialField({ spec, state, value, onChange, check }: Credent
|
|
|
116
116
|
sx={{ display: 'block', fontSize: '12px', fontWeight: 650, color: T.ink2, mb: '5px' }}
|
|
117
117
|
>
|
|
118
118
|
{spec.label}
|
|
119
|
-
{spec.advanced ? (
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
{spec.advanced || spec.optional ? (
|
|
120
|
+
// `ink3`, not `ink4`: "opcional" carries real information, and at 12px
|
|
121
|
+
// the lighter ink fell under AA contrast.
|
|
122
|
+
<Box component="span" sx={{ fontWeight: 500, color: spec.optional ? T.ink3 : T.ink4 }}>
|
|
123
|
+
{spec.advanced ? copy.advancedSuffix : copy.optionalSuffix}
|
|
122
124
|
</Box>
|
|
123
125
|
) : null}
|
|
124
126
|
</Typography>
|
|
@@ -189,12 +189,20 @@ export function ReverifyWarning({ displayName }: { displayName: string }) {
|
|
|
189
189
|
* Nothing is learned by separating them: the probe reads what was just stored,
|
|
190
190
|
* so the only useful moment to run it is immediately after a save.
|
|
191
191
|
*/
|
|
192
|
+
/** What the bar promises: testing now, what a save will do, or that it only writes down. */
|
|
193
|
+
function barMessage(copy: CredentialFormCopy, verifying: boolean, willTest: boolean, local: boolean): string {
|
|
194
|
+
if (verifying) return (local ? copy.probeLocalRunning : undefined) ?? copy.probeRunning;
|
|
195
|
+
if (!willTest) return copy.probeIncompleteNote;
|
|
196
|
+
return (local ? copy.probeLocalSaveNote : undefined) ?? copy.probeSaveNote;
|
|
197
|
+
}
|
|
198
|
+
|
|
192
199
|
export function FormActions({
|
|
193
200
|
busy,
|
|
194
201
|
label,
|
|
195
202
|
willTest,
|
|
196
203
|
disabled,
|
|
197
204
|
onSave,
|
|
205
|
+
local = false,
|
|
198
206
|
}: {
|
|
199
207
|
busy: string | null;
|
|
200
208
|
label: string;
|
|
@@ -202,17 +210,14 @@ export function FormActions({
|
|
|
202
210
|
willTest: boolean;
|
|
203
211
|
disabled: boolean;
|
|
204
212
|
onSave: () => void;
|
|
213
|
+
/** The "test" is a local check (`checksLocally`): nothing is sent to a provider. */
|
|
214
|
+
local?: boolean;
|
|
205
215
|
}) {
|
|
206
216
|
// The bar states what pressing it costs, which is the half a verb cannot
|
|
207
217
|
// carry: a complete set is sent to the provider on save, and a partial one is
|
|
208
218
|
// only written down. The owner reads the promise and the button in one line.
|
|
209
219
|
const copy = usePaymentsSettingsCopy().credentials;
|
|
210
|
-
const message =
|
|
211
|
-
busy === 'verify'
|
|
212
|
-
? copy.probeRunning
|
|
213
|
-
: willTest
|
|
214
|
-
? copy.probeSaveNote
|
|
215
|
-
: copy.probeIncompleteNote;
|
|
220
|
+
const message = barMessage(copy, busy === 'verify', willTest, local);
|
|
216
221
|
|
|
217
222
|
return (
|
|
218
223
|
<Box sx={BAR_SX} data-testid="payments-form-bar">
|
|
@@ -226,12 +226,49 @@ function oauthWalkthrough(props: ActivePanelProps, path: 'oauth' | 'credentials'
|
|
|
226
226
|
* a FUNCTION: which steps to show depends on the disclosure, whose state lives
|
|
227
227
|
* in `OAuthPanel`.
|
|
228
228
|
*/
|
|
229
|
+
/** The one environment a provider has, when it declares a single one. */
|
|
230
|
+
function soleEnvironment(descriptor: ActivePanelProps['descriptor']): PaymentEnvironment | null {
|
|
231
|
+
// Read defensively: a host-built descriptor (and many fixtures) may carry no capabilities at all.
|
|
232
|
+
const declared = (descriptor.capabilities as typeof descriptor.capabilities | undefined)?.environments;
|
|
233
|
+
return declared && declared.length === 1 ? (declared[0] ?? null) : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The environment selector and its notice — full-bleed across the card, and
|
|
238
|
+
* inset for the manual disclosure, where it no longer spans anything (see
|
|
239
|
+
* `EnvironmentNotice`). A provider with ONE environment (no sandbox: every code
|
|
240
|
+
* is real money) gets neither: there is nothing to choose, and a "test
|
|
241
|
+
* environment" notice would promise that no money moves.
|
|
242
|
+
*/
|
|
243
|
+
function environmentChrome(
|
|
244
|
+
sole: PaymentEnvironment | null,
|
|
245
|
+
environment: PaymentEnvironment,
|
|
246
|
+
onChange: (next: PaymentEnvironment) => void,
|
|
247
|
+
active: PaymentEnvironment | null,
|
|
248
|
+
): { selector: ReactNode; band: ReactNode; notice: ReactNode } {
|
|
249
|
+
// Nothing to choose with one environment. The notice goes only for a
|
|
250
|
+
// PRODUCTION-only provider (it would promise no money moves); a sandbox-only
|
|
251
|
+
// one keeps its truthful "nothing is real" notice.
|
|
252
|
+
if (sole === 'PRODUCTION') return { selector: null, band: undefined, notice: null };
|
|
253
|
+
if (sole) {
|
|
254
|
+
return {
|
|
255
|
+
selector: null,
|
|
256
|
+
band: <EnvironmentNotice environment={environment} active={active} />,
|
|
257
|
+
notice: <EnvironmentNotice environment={environment} active={active} band={false} />,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
selector: <EnvironmentSelector environment={environment} onChange={onChange} />,
|
|
262
|
+
band: <EnvironmentNotice environment={environment} active={active} />,
|
|
263
|
+
notice: <EnvironmentNotice environment={environment} active={active} band={false} />,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
229
267
|
export function ActivePanel(props: ActivePanelProps) {
|
|
230
268
|
const { descriptor, config, client, onChanged, reload, guide, verification } = props;
|
|
231
269
|
const statusBar = <EnableBar {...props} />;
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
);
|
|
270
|
+
const sole = soleEnvironment(descriptor);
|
|
271
|
+
const [environment, setEnvironment] = useState<PaymentEnvironment>(sole ?? config?.environment ?? 'SANDBOX');
|
|
235
272
|
const [editing, setEditing] = useState(false);
|
|
236
273
|
|
|
237
274
|
const oauthConnect = oauthWithConnect(props);
|
|
@@ -251,12 +288,7 @@ export function ActivePanel(props: ActivePanelProps) {
|
|
|
251
288
|
/>
|
|
252
289
|
);
|
|
253
290
|
|
|
254
|
-
const selector =
|
|
255
|
-
// Full-bleed across the card, and the same notice inset for the manual
|
|
256
|
-
// disclosure, where it no longer spans anything. See `EnvironmentNotice`.
|
|
257
|
-
const active = config?.environment ?? null;
|
|
258
|
-
const band = <EnvironmentNotice environment={environment} active={active} />;
|
|
259
|
-
const notice = <EnvironmentNotice environment={environment} active={active} band={false} />;
|
|
291
|
+
const { selector, band, notice } = environmentChrome(sole, environment, setEnvironment, config?.environment ?? null);
|
|
260
292
|
|
|
261
293
|
const storedHere = requiredStored(descriptor, config, environment);
|
|
262
294
|
|
|
@@ -5,6 +5,7 @@ import { Box, Stack, Switch, Tooltip, Typography } from '@mui/material';
|
|
|
5
5
|
import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
6
|
|
|
7
7
|
import { certificateExpiryProximity, isConnected } from './connection-state';
|
|
8
|
+
import { checksLocally } from './checks-locally';
|
|
8
9
|
import { T } from './panel-tokens';
|
|
9
10
|
import type { ConnectionStatusCopy } from './settings-copy';
|
|
10
11
|
import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
@@ -54,7 +55,9 @@ export function statusBadge(
|
|
|
54
55
|
if (config?.status === 'RECONNECT_REQUIRED') {
|
|
55
56
|
return { label: copy.reconnectRequired, color: 'error' };
|
|
56
57
|
}
|
|
57
|
-
if (isConnected(config))
|
|
58
|
+
if (isConnected(config)) {
|
|
59
|
+
return { label: (checksLocally(descriptor) ? copy.detailsOk : undefined) ?? copy.connectionOk, color: 'info' };
|
|
60
|
+
}
|
|
58
61
|
// A FAILED probe lands here, and it says NÃO VERIFICADO like every other
|
|
59
62
|
// not-yet-connected state. `FALHOU` was a third word for the same fact — the
|
|
60
63
|
// store cannot take money through this provider yet — and it was the only
|
|
@@ -125,7 +128,7 @@ function toggleGate(
|
|
|
125
128
|
if (!lockedOff) return { lockedOff, hint: '' };
|
|
126
129
|
return {
|
|
127
130
|
lockedOff,
|
|
128
|
-
hint: provable ? copy.threeStepsAhead : copy.connectAndVerifyFirst,
|
|
131
|
+
hint: provable ? copy.threeStepsAhead : ((checksLocally(descriptor) ? copy.checkDetailsFirst : undefined) ?? copy.connectAndVerifyFirst),
|
|
129
132
|
};
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -186,12 +186,25 @@ export function SetupGuideSection({
|
|
|
186
186
|
// have to render: they are the credential form, and swallowing them left a
|
|
187
187
|
// provider with a status chip, a pair of environment tabs and no way to type
|
|
188
188
|
// anything in.
|
|
189
|
+
// They take the card's 20px inset themselves, as every block in its
|
|
190
|
+
// unpadded body does (`ProviderCard`) — a guide's step panel carries it for
|
|
191
|
+
// them, and without one the boxes ran into the card's edges.
|
|
189
192
|
if (!guide) {
|
|
190
193
|
return (
|
|
191
|
-
|
|
194
|
+
// The save bar and the saved-details row carry their own inset for the
|
|
195
|
+
// guided layout; here the wrapper already supplies it, so theirs is zeroed
|
|
196
|
+
// and their rule, text and button line up with the fields.
|
|
197
|
+
<Box
|
|
198
|
+
data-testid="payments-setup-unguided"
|
|
199
|
+
sx={{
|
|
200
|
+
px: '20px',
|
|
201
|
+
'& [data-testid="payments-form-bar"]': { px: 0 },
|
|
202
|
+
'& [data-testid="payments-credential-summary"]': { mx: 0 },
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
192
205
|
{rows}
|
|
193
206
|
{sectionFooter}
|
|
194
|
-
|
|
207
|
+
</Box>
|
|
195
208
|
);
|
|
196
209
|
}
|
|
197
210
|
|
|
@@ -59,11 +59,14 @@ interface LiveWait {
|
|
|
59
59
|
type LoopRef = { readonly current: PollLoop | null };
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
* once that quiet window is over. The
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
62
|
+
* A channel that DROPS: ask now — and if the loop declines because it asked a
|
|
63
|
+
* moment ago, ask again once that quiet window is over. The ask it lands just
|
|
64
|
+
* after may have been answered before the drop, and its next tick sleeps out
|
|
65
|
+
* the live floor, so declining for good would leave the buyer waiting with
|
|
66
|
+
* nobody left to wake them.
|
|
67
|
+
*
|
|
68
|
+
* A HINT does not come here. It is `PollLoop.hint` (FUT-3205): deferring a
|
|
69
|
+
* hint by the quiet window cost a paid buyer a full second on the pay step.
|
|
67
70
|
*/
|
|
68
71
|
function wake(loop: LoopRef, pending: { timer?: ReturnType<typeof setTimeout> }): void {
|
|
69
72
|
if (loop.current?.poke() !== false) return;
|
|
@@ -94,7 +97,7 @@ export function useLiveWait(loop: LoopRef): LiveWait {
|
|
|
94
97
|
useEffect(() => {
|
|
95
98
|
if (!subscribe) return undefined;
|
|
96
99
|
const retry = pending.current;
|
|
97
|
-
const unsubscribe = subscribe(() =>
|
|
100
|
+
const unsubscribe = subscribe(() => loop.current?.hint());
|
|
98
101
|
return () => {
|
|
99
102
|
unsubscribe();
|
|
100
103
|
clearTimeout(retry.timer);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { withLive } from "./poll-live";
|
|
2
|
+
import type { PollingOptions } from "./poll-loop";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long a payment wait sleeps before its next ask — pure functions of the
|
|
6
|
+
* options and the run's counters, split out of `poll-loop.ts` so the loop
|
|
7
|
+
* stays one question: WHEN to ask, not how long.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The slowest a failing poll may go (FUT-1144).
|
|
12
|
+
*
|
|
13
|
+
* Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
|
|
14
|
+
* The cap is what keeps the recovery cheap: a shopper whose signal comes back
|
|
15
|
+
* during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
|
|
16
|
+
* the re-arm events (`poll-rearm.ts`) usually beat it outright.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How long before the next ask, given how many healthy polls have happened.
|
|
22
|
+
*
|
|
23
|
+
* A pure function of the options, so it lives out here rather than inside the
|
|
24
|
+
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
25
|
+
* read (and to test) stated once than threaded through a closure.
|
|
26
|
+
*
|
|
27
|
+
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
28
|
+
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
29
|
+
* fast polls" has to actually make N of them.
|
|
30
|
+
*/
|
|
31
|
+
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
32
|
+
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
33
|
+
const backingOff =
|
|
34
|
+
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
35
|
+
return withLive(backingOff ? slowIntervalMs : intervalMs, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
40
|
+
*
|
|
41
|
+
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
42
|
+
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
43
|
+
* whole confirmation: four failures used to be terminal, and four failures is
|
|
44
|
+
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
45
|
+
*
|
|
46
|
+
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
47
|
+
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
48
|
+
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
49
|
+
*/
|
|
50
|
+
export function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
51
|
+
const base = healthyDelay(healthy, options);
|
|
52
|
+
if (errors === 0) return base;
|
|
53
|
+
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { claimRearm, REARM_QUIET_MS } from "./poll-rearm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a realtime hint does to a running payment wait (FUT-3205).
|
|
5
|
+
*
|
|
6
|
+
* A hint is NOT a re-arm. `poke`'s quiet window exists to collapse
|
|
7
|
+
* `visibilitychange` and `online` bursts, and a hint is a server fact.
|
|
8
|
+
* Deferring it by that window cost a paid buyer a full second on the pay step
|
|
9
|
+
* (P-040: the hint at 12,145 ms, the second read at 13,148 ms). So, by the
|
|
10
|
+
* loop's state:
|
|
11
|
+
*
|
|
12
|
+
* - The wait ended (settled, stopped, cancelled): nothing (FUT-1144).
|
|
13
|
+
* - No ask in flight: ask now.
|
|
14
|
+
* - An ask in flight for `REARM_QUIET_MS` or longer: supersede it the way
|
|
15
|
+
* `poke` does, within `claimRearm`'s budget — the hung-socket case (FUT-1259).
|
|
16
|
+
* - Otherwise, or with that budget spent: keep the ask and MARK the run. One
|
|
17
|
+
* more ask is owed the moment it answers non-terminal, so a hint is never
|
|
18
|
+
* dropped (FUT-649), and a burst of hints during one ask costs one extra ask.
|
|
19
|
+
*
|
|
20
|
+
* The mark is cleared by `restart`, by a `poke` that supersedes the marked
|
|
21
|
+
* ask, by sending its follow-up and by a terminal answer. The deadline does
|
|
22
|
+
* NOT clear it: a marked ask answering after the deadline is caught by
|
|
23
|
+
* {@link owedAfterAnswer} and neither asks nor schedules.
|
|
24
|
+
*
|
|
25
|
+
* Structural state, as in `poll-rearm.ts`: the loop's run satisfies it.
|
|
26
|
+
*/
|
|
27
|
+
interface HintState {
|
|
28
|
+
cancelled: boolean;
|
|
29
|
+
settled: boolean;
|
|
30
|
+
stopped: boolean;
|
|
31
|
+
inFlight: boolean;
|
|
32
|
+
askedAt: number;
|
|
33
|
+
supersededAsks: number;
|
|
34
|
+
hinted: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Take a hint: whether the loop must ask now. Marks the run when it may not. */
|
|
38
|
+
export function claimHint(run: HintState): boolean {
|
|
39
|
+
if (run.cancelled || run.settled || run.stopped) return false;
|
|
40
|
+
const young = run.inFlight && Date.now() - run.askedAt < REARM_QUIET_MS;
|
|
41
|
+
if (!run.inFlight || (!young && claimRearm(run))) {
|
|
42
|
+
run.hinted = false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
run.hinted = true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* After a non-terminal answer: `"ask"` when a mark owes one more ask now,
|
|
51
|
+
* `"ended"` when the wait stopped while that ask was out, `"none"` when nothing
|
|
52
|
+
* is owed and the loop schedules as usual. Clears the mark.
|
|
53
|
+
*/
|
|
54
|
+
export function owedAfterAnswer(run: Pick<HintState, "hinted" | "stopped">): "ask" | "ended" | "none" {
|
|
55
|
+
if (!run.hinted) return "none";
|
|
56
|
+
run.hinted = false;
|
|
57
|
+
return run.stopped ? "ended" : "ask";
|
|
58
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Result } from "../../result";
|
|
2
|
-
import {
|
|
2
|
+
import { pollDelay } from "./poll-delay";
|
|
3
|
+
import { waitsForDeadline, type LiveCadence } from "./poll-live";
|
|
4
|
+
import { claimHint, owedAfterAnswer } from "./poll-hint";
|
|
3
5
|
import { claimRearm } from "./poll-rearm";
|
|
4
6
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
5
7
|
|
|
@@ -64,16 +66,6 @@ export interface PollingOptions extends LiveCadence {
|
|
|
64
66
|
slowIntervalMs?: number;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
/**
|
|
68
|
-
* The slowest a failing poll may go (FUT-1144).
|
|
69
|
-
*
|
|
70
|
-
* Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
|
|
71
|
-
* The cap is what keeps the recovery cheap: a shopper whose signal comes back
|
|
72
|
-
* during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
|
|
73
|
-
* the two re-arm events below usually beat it outright.
|
|
74
|
-
*/
|
|
75
|
-
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
76
|
-
|
|
77
69
|
/**
|
|
78
70
|
* How long ONE status ask may take before the loop stops waiting on it.
|
|
79
71
|
*
|
|
@@ -92,42 +84,6 @@ const DEFAULT_ASK_TIMEOUT_MS = 15_000;
|
|
|
92
84
|
*/
|
|
93
85
|
const ASK_TIMED_OUT = "timeout";
|
|
94
86
|
|
|
95
|
-
/**
|
|
96
|
-
* How long before the next ask, given how many healthy polls have happened.
|
|
97
|
-
*
|
|
98
|
-
* A pure function of the options, so it lives out here rather than inside the
|
|
99
|
-
* effect — the hook is at its size gate, and a scheduling RULE is easier to
|
|
100
|
-
* read (and to test) stated once than threaded through a closure.
|
|
101
|
-
*
|
|
102
|
-
* Reads the count AFTER the poll just made, so the slow phase begins on the
|
|
103
|
-
* poll FOLLOWING the threshold rather than one early: a wait described as "N
|
|
104
|
-
* fast polls" has to actually make N of them.
|
|
105
|
-
*/
|
|
106
|
-
function healthyDelay(healthy: number, options: PollingOptions): number {
|
|
107
|
-
const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
|
|
108
|
-
const backingOff =
|
|
109
|
-
slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
|
|
110
|
-
return withLive(backingOff ? slowIntervalMs : intervalMs, options);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The same rule with consecutive FAILURES folded in (FUT-1144).
|
|
115
|
-
*
|
|
116
|
-
* Errors never stop the wait, they only slow it. Doubling from the healthy
|
|
117
|
-
* cadence is what makes a ten-second blip cost one extra beat instead of the
|
|
118
|
-
* whole confirmation: four failures used to be terminal, and four failures is
|
|
119
|
-
* what a Wi-Fi→4G handoff produces at the default 2.5 s.
|
|
120
|
-
*
|
|
121
|
-
* The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
|
|
122
|
-
* larger — a failing poll must never ask FASTER than a succeeding one, which
|
|
123
|
-
* is what a bare cap would do to a consumer whose slow interval is longer.
|
|
124
|
-
*/
|
|
125
|
-
function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
|
|
126
|
-
const base = healthyDelay(healthy, options);
|
|
127
|
-
if (errors === 0) return base;
|
|
128
|
-
return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
87
|
/** Where a running wait writes what it has learned. */
|
|
132
88
|
export interface PollSink {
|
|
133
89
|
setStatus: (status: OrderStatus) => void;
|
|
@@ -166,6 +122,8 @@ function newRun() {
|
|
|
166
122
|
askedAt: 0,
|
|
167
123
|
/** Live asks abandoned by a re-arm since the last answer. See `claimRearm`. */
|
|
168
124
|
supersededAsks: 0,
|
|
125
|
+
/** A hint owes one more ask once the one in flight answers. See `poll-hint.ts`. */
|
|
126
|
+
hinted: false,
|
|
169
127
|
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
170
128
|
/**
|
|
171
129
|
* The wall clock, as a timer rather than a check after an ask. `outOfTime`
|
|
@@ -314,12 +272,29 @@ function scheduleNext(
|
|
|
314
272
|
run.timer = setTimeout(again, delay);
|
|
315
273
|
}
|
|
316
274
|
|
|
275
|
+
/** Abandon whatever ask is current and ask now, keeping the clock. */
|
|
276
|
+
function askNow(run: PollRun, tick: () => Promise<void>): void {
|
|
277
|
+
run.attempt += 1;
|
|
278
|
+
run.inFlight = false;
|
|
279
|
+
clearPending(run);
|
|
280
|
+
void tick();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** After a non-terminal answer: the ask a hint owes, or the usual schedule. */
|
|
284
|
+
function continueAfter(run: PollRun, tick: () => Promise<void>, schedule: () => void): void {
|
|
285
|
+
const owed = owedAfterAnswer(run);
|
|
286
|
+
if (owed === "ask") askNow(run, tick);
|
|
287
|
+
else if (owed === "none") schedule();
|
|
288
|
+
}
|
|
289
|
+
|
|
317
290
|
/** The handle the hook holds on one running wait. */
|
|
318
291
|
export interface PollLoop {
|
|
319
292
|
/** Reset the clock and the counters, then ask immediately. */
|
|
320
293
|
restart: () => void;
|
|
321
294
|
/** Ask immediately, keeping the clock — the re-arm events' entry point. Whether it asked. */
|
|
322
295
|
poke: () => boolean;
|
|
296
|
+
/** A realtime hint: ask now, or owe one ask after the current one (`poll-hint.ts`, FUT-3205). */
|
|
297
|
+
hint: () => void;
|
|
323
298
|
/** Tear down: nothing further is scheduled and nothing further is written. */
|
|
324
299
|
stop: () => void;
|
|
325
300
|
}
|
|
@@ -353,11 +328,12 @@ export function createPollLoop(
|
|
|
353
328
|
if (run.attempt === mine) run.inFlight = false;
|
|
354
329
|
if (run.cancelled || run.settled) return;
|
|
355
330
|
if (!absorb(run, result, sink)) {
|
|
331
|
+
run.hinted = false;
|
|
356
332
|
clearDeadline(run);
|
|
357
333
|
return;
|
|
358
334
|
}
|
|
359
335
|
if (run.attempt !== mine) return;
|
|
360
|
-
scheduleNext(run, options, sink, () => void tick());
|
|
336
|
+
continueAfter(run, tick, () => scheduleNext(run, options, sink, () => void tick()));
|
|
361
337
|
};
|
|
362
338
|
|
|
363
339
|
return {
|
|
@@ -372,6 +348,7 @@ export function createPollLoop(
|
|
|
372
348
|
run.errors = 0;
|
|
373
349
|
run.healthy = 0;
|
|
374
350
|
run.supersededAsks = 0;
|
|
351
|
+
run.hinted = false;
|
|
375
352
|
run.startedAt = Date.now();
|
|
376
353
|
armDeadline(run, options, sink);
|
|
377
354
|
sink.setTimedOut(false);
|
|
@@ -385,12 +362,14 @@ export function createPollLoop(
|
|
|
385
362
|
// socket that died while the screen was hidden. Abandon it and ask now —
|
|
386
363
|
// but `claimRearm` bounds how many live asks that may abandon in a row.
|
|
387
364
|
if (!claimRearm(run)) return false;
|
|
388
|
-
|
|
389
|
-
run.
|
|
390
|
-
|
|
391
|
-
void tick();
|
|
365
|
+
// The new ask is sent after any hint that marked the old one.
|
|
366
|
+
run.hinted = false;
|
|
367
|
+
askNow(run, tick);
|
|
392
368
|
return true;
|
|
393
369
|
},
|
|
370
|
+
hint: (): void => {
|
|
371
|
+
if (claimHint(run)) askNow(run, tick);
|
|
372
|
+
},
|
|
394
373
|
stop: (): void => {
|
|
395
374
|
run.cancelled = true;
|
|
396
375
|
clearPending(run);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProviderDescriptor } from '@12-apps/payments-backend';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether this provider's "test" is a LOCAL check — there is no provider to
|
|
5
|
+
* connect to (the store's own Pix key, `capabilities.confirmation: 'MANUAL'`).
|
|
6
|
+
* Its screen must not say keys are tested "no provedor" or call a passing
|
|
7
|
+
* check a CONNECTION. Read defensively: a host-built descriptor may carry no
|
|
8
|
+
* capabilities.
|
|
9
|
+
*/
|
|
10
|
+
export function checksLocally(descriptor: Pick<ProviderDescriptor, 'capabilities'> | undefined): boolean {
|
|
11
|
+
const capabilities = descriptor?.capabilities as ProviderDescriptor['capabilities'] | undefined;
|
|
12
|
+
return capabilities?.confirmation === 'MANUAL';
|
|
13
|
+
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ProviderDescriptor,
|
|
6
6
|
} from '@12-apps/payments-backend';
|
|
7
7
|
|
|
8
|
+
import { checksLocally } from './checks-locally';
|
|
8
9
|
import type { PendingSave } from './ConfirmCredentialSave';
|
|
9
10
|
import type { CredentialFormCopy } from './settings-copy';
|
|
10
11
|
|
|
@@ -49,7 +50,7 @@ export function credentialsComplete(
|
|
|
49
50
|
): boolean {
|
|
50
51
|
const stored = config?.environments[environment] ?? {};
|
|
51
52
|
return descriptor.credentialSchema.every((spec) => {
|
|
52
|
-
if (spec.advanced || !appliesIn(spec, environment)) return true;
|
|
53
|
+
if (spec.advanced || spec.optional || !appliesIn(spec, environment)) return true;
|
|
53
54
|
const typed = values[spec.key];
|
|
54
55
|
if (typed !== undefined && typed.trim() !== '') return true;
|
|
55
56
|
return stored[spec.key]?.configured === true;
|
|
@@ -104,10 +105,15 @@ export function saveLabel(
|
|
|
104
105
|
): string {
|
|
105
106
|
const required = descriptor.credentialSchema.filter((field) => field.required);
|
|
106
107
|
const only = required.length === 1 ? required[0] : undefined;
|
|
107
|
-
if (!only) return complete ? copy
|
|
108
|
+
if (!only) return complete ? saveAndTestLabel(copy, descriptor) : copy.save;
|
|
108
109
|
return copy.saveOnly(only.label.replace(/\s*\([^)]*\)\s*$/, ''));
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** "Salvar e testar conexão", or "Salvar e conferir" where nothing leaves for a provider. */
|
|
113
|
+
function saveAndTestLabel(copy: CredentialFormCopy, descriptor: ProviderDescriptor): string {
|
|
114
|
+
return (checksLocally(descriptor) ? copy.saveAndCheck : undefined) ?? copy.saveAndTest;
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
/**
|
|
112
118
|
* The stored value of the field a finished step collapses around.
|
|
113
119
|
*
|
|
@@ -30,6 +30,12 @@ export interface ConnectionStatusCopy {
|
|
|
30
30
|
unverified: string;
|
|
31
31
|
/** The credential probe passed; no charge has been made. */
|
|
32
32
|
connectionOk: string;
|
|
33
|
+
/**
|
|
34
|
+
* `connectionOk` for a provider there is nothing to connect to — the store's
|
|
35
|
+
* own Pix key, checked here (`checksLocally`). Optional: absent, the chip
|
|
36
|
+
* falls back to `connectionOk`.
|
|
37
|
+
*/
|
|
38
|
+
detailsOk?: string;
|
|
33
39
|
/**
|
|
34
40
|
* A connection that WAS working and stopped — the one red chip, because it
|
|
35
41
|
* is news rather than a step still to finish.
|
|
@@ -41,6 +47,8 @@ export interface ConnectionStatusCopy {
|
|
|
41
47
|
threeStepsAhead: string;
|
|
42
48
|
/** The same, when the store must connect and verify first. */
|
|
43
49
|
connectAndVerifyFirst: string;
|
|
50
|
+
/** `connectAndVerifyFirst` for a provider checked locally. Optional, falls back to it. */
|
|
51
|
+
checkDetailsFirst?: string;
|
|
44
52
|
/** Live: this provider is taking money. */
|
|
45
53
|
receiving: { state: string; sub: string };
|
|
46
54
|
/** Proven, and switched off by the owner. */
|
|
@@ -194,6 +202,8 @@ export interface CredentialFormCopy {
|
|
|
194
202
|
configuredKeepBlank: string;
|
|
195
203
|
/** The suffix on an `advanced` field most stores must leave empty. */
|
|
196
204
|
advancedSuffix: string;
|
|
205
|
+
/** The suffix on an `optional` field: blank keeps its default. */
|
|
206
|
+
optionalSuffix: string;
|
|
197
207
|
probeAction: string;
|
|
198
208
|
/**
|
|
199
209
|
* What replacing a PROVEN connection's credentials costs.
|
|
@@ -206,6 +216,13 @@ export interface CredentialFormCopy {
|
|
|
206
216
|
reverifyWarning(displayName: string): string;
|
|
207
217
|
probeRunning: string;
|
|
208
218
|
probeSaveNote: string;
|
|
219
|
+
/**
|
|
220
|
+
* `probeRunning` / `probeSaveNote` for a provider checked locally: nothing is
|
|
221
|
+
* sent anywhere, so saying the keys are tested "no provedor" would be false.
|
|
222
|
+
* Optional; absent, the provider sentences are used.
|
|
223
|
+
*/
|
|
224
|
+
probeLocalRunning?: string;
|
|
225
|
+
probeLocalSaveNote?: string;
|
|
209
226
|
probeIncompleteNote: string;
|
|
210
227
|
probeFailed(environmentName: string): string;
|
|
211
228
|
/** The per-credential marks beside each field. */
|
|
@@ -219,6 +236,8 @@ export interface CredentialFormCopy {
|
|
|
219
236
|
* only written down, and a one-field provider names its field instead.
|
|
220
237
|
*/
|
|
221
238
|
saveAndTest: string;
|
|
239
|
+
/** `saveAndTest` for a provider checked locally (no connection to test). Optional. */
|
|
240
|
+
saveAndCheck?: string;
|
|
222
241
|
save: string;
|
|
223
242
|
saveOnly(fieldLabel: string): string;
|
|
224
243
|
/** Reopen a finished credential step — unaccented, so only a reading found it. */
|
|
@@ -22,10 +22,12 @@ export const EN_US_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
22
22
|
verified: 'VERIFIED',
|
|
23
23
|
unverified: 'NOT VERIFIED',
|
|
24
24
|
connectionOk: 'CONNECTION OK',
|
|
25
|
+
detailsOk: 'DETAILS OK',
|
|
25
26
|
reconnectRequired: 'RECONNECT',
|
|
26
27
|
notConnected: 'Not connected',
|
|
27
28
|
threeStepsAhead: 'This provider only starts taking sales after the 3 steps below.',
|
|
28
29
|
connectAndVerifyFirst: 'Connect and verify the provider before switching sales on.',
|
|
30
|
+
checkDetailsFirst: 'Fill in and check the details before switching sales on.',
|
|
29
31
|
receiving: {
|
|
30
32
|
state: 'Taking sales',
|
|
31
33
|
sub: 'Your store is being paid through this provider.',
|
|
@@ -131,12 +133,15 @@ export const EN_US_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
131
133
|
configuredKeepBlank: 'Configured — leave blank to keep the current value.',
|
|
132
134
|
// Leading space: it is appended to a field label.
|
|
133
135
|
advancedSuffix: ' · Connect platforms only',
|
|
136
|
+
optionalSuffix: ' · optional',
|
|
134
137
|
probeAction: 'Test connection',
|
|
135
138
|
reverifyWarning: (displayName) =>
|
|
136
139
|
`This store is already verified. Changing the credentials **requires re-verification** ` +
|
|
137
140
|
`and the store **stops taking payments** through ${displayName} until it finishes.`,
|
|
138
141
|
probeRunning: 'Testing the connection…',
|
|
139
142
|
probeSaveNote: 'We save and test the keys with the provider before going on.',
|
|
143
|
+
probeLocalRunning: 'Checking the details…',
|
|
144
|
+
probeLocalSaveNote: 'We check the details before saving.',
|
|
140
145
|
probeIncompleteNote: 'We kept what you have filled in. Complete the fields to test.',
|
|
141
146
|
probeFailed: (environmentName) =>
|
|
142
147
|
`Could not connect in ${environmentName}. Check the credentials for this environment.`,
|
|
@@ -144,6 +149,7 @@ export const EN_US_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
144
149
|
checkFail: 'Fix',
|
|
145
150
|
uncheckable: 'Not verifiable',
|
|
146
151
|
saveAndTest: 'Save and test connection',
|
|
152
|
+
saveAndCheck: 'Save and check',
|
|
147
153
|
save: 'Save',
|
|
148
154
|
saveOnly: (fieldLabel) => `Save ${fieldLabel}`,
|
|
149
155
|
changeAction: 'Change',
|
|
@@ -14,10 +14,12 @@ export const PT_BR_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
14
14
|
verified: 'VERIFICADO',
|
|
15
15
|
unverified: 'NÃO VERIFICADO',
|
|
16
16
|
connectionOk: 'CONEXÃO OK',
|
|
17
|
+
detailsOk: 'DADOS OK',
|
|
17
18
|
reconnectRequired: 'RECONECTAR',
|
|
18
19
|
notConnected: 'Não conectado',
|
|
19
20
|
threeStepsAhead: 'Este provedor só passa a receber vendas depois dos 3 passos abaixo.',
|
|
20
21
|
connectAndVerifyFirst: 'Conecte e verifique o provedor antes de ativar as vendas.',
|
|
22
|
+
checkDetailsFirst: 'Preencha e confira os dados antes de ativar as vendas.',
|
|
21
23
|
receiving: {
|
|
22
24
|
state: 'Recebendo vendas',
|
|
23
25
|
sub: 'Sua loja está recebendo por este provedor.',
|
|
@@ -119,12 +121,15 @@ export const PT_BR_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
119
121
|
credentials: {
|
|
120
122
|
configuredKeepBlank: 'Configurado — deixe em branco para manter o valor atual.',
|
|
121
123
|
advancedSuffix: ' · só para plataformas Connect',
|
|
124
|
+
optionalSuffix: ' · opcional',
|
|
122
125
|
probeAction: 'Testar conexão',
|
|
123
126
|
reverifyWarning: (displayName) =>
|
|
124
127
|
`Esta loja já está verificada. Trocar as credenciais **exige uma nova verificação** ` +
|
|
125
128
|
`e a loja **para de receber** pelo ${displayName} até que ela termine.`,
|
|
126
129
|
probeRunning: 'Testando a conexão…',
|
|
127
130
|
probeSaveNote: 'Salvamos e testamos as chaves no provedor antes de seguir.',
|
|
131
|
+
probeLocalRunning: 'Conferindo os dados…',
|
|
132
|
+
probeLocalSaveNote: 'Conferimos os dados antes de salvar.',
|
|
128
133
|
probeIncompleteNote: 'Guardamos o que você já preencheu. Complete os campos para testar.',
|
|
129
134
|
probeFailed: (environmentName) =>
|
|
130
135
|
`Não foi possível conectar em ${environmentName}. Confira as credenciais deste ambiente.`,
|
|
@@ -132,6 +137,7 @@ export const PT_BR_PAYMENTS_SETTINGS_COPY: PaymentsSettingsCopy = {
|
|
|
132
137
|
checkFail: 'Corrigir',
|
|
133
138
|
uncheckable: 'Não verificável',
|
|
134
139
|
saveAndTest: 'Salvar e testar conexão',
|
|
140
|
+
saveAndCheck: 'Salvar e conferir',
|
|
135
141
|
save: 'Salvar',
|
|
136
142
|
saveOnly: (fieldLabel) => `Salvar ${fieldLabel}`,
|
|
137
143
|
changeAction: 'Alterar',
|