@12-apps/payments-frontend 3.27.2 → 3.28.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 +6 -3
- package/src/components/CredentialFields.tsx +17 -1
- package/src/components/PaymentProviderSettings.tsx +22 -0
- package/src/components/ProviderCredentialForm.tsx +1 -1
- package/src/components/ProviderSetupGuide.tsx +5 -2
- package/src/components/checkout/providers/hands-over.ts +22 -2
- package/src/components/checkout-payment-views.tsx +15 -1
- package/src/components/credential-rules.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.28.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.28.0",
|
|
27
27
|
"react-qr-code": "^2.2.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { Stack } from '@mui/material';
|
|
4
4
|
|
|
5
|
-
import type { ProviderDescriptor } from '@12-apps/payments-backend';
|
|
5
|
+
import type { PaymentEnvironment, ProviderDescriptor } from '@12-apps/payments-backend';
|
|
6
6
|
|
|
7
7
|
import { CredentialField } from './CredentialFields';
|
|
8
|
-
import { saveLabel } from './credential-rules';
|
|
8
|
+
import { appliesIn, saveLabel } from './credential-rules';
|
|
9
9
|
import { FormActions, ReverifyWarning } from './CredentialFormAlerts';
|
|
10
10
|
import type { CredentialFormState } from './ProviderCredentialForm';
|
|
11
11
|
import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
@@ -20,10 +20,13 @@ import { usePaymentsSettingsCopy } from './settings-copy-context';
|
|
|
20
20
|
*/
|
|
21
21
|
export function CredentialFields({
|
|
22
22
|
descriptor,
|
|
23
|
+
environment,
|
|
23
24
|
form,
|
|
24
25
|
proven,
|
|
25
26
|
}: {
|
|
26
27
|
descriptor: ProviderDescriptor;
|
|
28
|
+
/** The tab on screen: a field declared for other environments is not drawn. */
|
|
29
|
+
environment: PaymentEnvironment;
|
|
27
30
|
form: CredentialFormState;
|
|
28
31
|
/** A real charge has landed through this connection — see `ReverifyWarning`. */
|
|
29
32
|
proven: boolean;
|
|
@@ -32,7 +35,7 @@ export function CredentialFields({
|
|
|
32
35
|
return (
|
|
33
36
|
<Stack spacing={2}>
|
|
34
37
|
{proven ? <ReverifyWarning displayName={descriptor.displayName} /> : null}
|
|
35
|
-
{descriptor.credentialSchema.map((spec) => (
|
|
38
|
+
{descriptor.credentialSchema.filter((spec) => appliesIn(spec, environment)).map((spec) => (
|
|
36
39
|
<CredentialField
|
|
37
40
|
key={spec.key}
|
|
38
41
|
spec={spec}
|
|
@@ -68,6 +68,16 @@ const MONO_INPUT = {
|
|
|
68
68
|
letterSpacing: '0.08em',
|
|
69
69
|
} as const;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* A pasted PEM keeps its lines: a single-line input folds them away and the
|
|
73
|
+
* owner cannot see what they pasted. A textarea cannot be `type="password"`,
|
|
74
|
+
* so a multiline secret is shown while typed — it is still write-only, never
|
|
75
|
+
* echoed back after saving (the placeholder carries the masked hint).
|
|
76
|
+
*/
|
|
77
|
+
function multilineProps(spec: CredentialFieldSpec) {
|
|
78
|
+
return spec.multiline ? { multiline: true, minRows: 4, maxRows: 10, type: undefined } : {};
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
interface CredentialFieldProps {
|
|
72
82
|
spec: CredentialFieldSpec;
|
|
73
83
|
state: MaskedFieldState | undefined;
|
|
@@ -117,9 +127,12 @@ export function CredentialField({ spec, state, value, onChange, check }: Credent
|
|
|
117
127
|
fullWidth
|
|
118
128
|
id={inputId}
|
|
119
129
|
{...presentation}
|
|
130
|
+
{...multilineProps(spec)}
|
|
120
131
|
helperText={undefined}
|
|
121
132
|
slotProps={
|
|
122
|
-
spec.mono
|
|
133
|
+
spec.mono || spec.multiline
|
|
134
|
+
? { htmlInput: { sx: MONO_INPUT, spellCheck: false } }
|
|
135
|
+
: { htmlInput: {} }
|
|
123
136
|
}
|
|
124
137
|
sx={fieldSx(check?.status)}
|
|
125
138
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -151,6 +164,9 @@ function fieldSx(status?: 'PASS' | 'FAIL' | 'UNCHECKED') {
|
|
|
151
164
|
'&.Mui-focused fieldset': { borderColor: T.brand, borderWidth: '2px' },
|
|
152
165
|
},
|
|
153
166
|
'& .MuiOutlinedInput-input': { padding: '10px 12px' },
|
|
167
|
+
// A multiline root pads itself too; the textarea's own padding above is
|
|
168
|
+
// the one that lines it up with the single-line boxes around it.
|
|
169
|
+
'& .MuiInputBase-multiline': { padding: 0 },
|
|
154
170
|
} as const;
|
|
155
171
|
}
|
|
156
172
|
|
|
@@ -177,6 +177,11 @@ interface VerificationInputs {
|
|
|
177
177
|
provider: string;
|
|
178
178
|
displayName: string;
|
|
179
179
|
config: MaskedProviderConfig | null;
|
|
180
|
+
/**
|
|
181
|
+
* Some activation flow can run for this provider at all — see
|
|
182
|
+
* {@link activationRunnable}. When none can, there is no step to show.
|
|
183
|
+
*/
|
|
184
|
+
provable: boolean;
|
|
180
185
|
/** An earlier step is unfinished: withhold the pay button, render the rest. */
|
|
181
186
|
blocked: boolean;
|
|
182
187
|
/** The walkthrough is on an earlier step; this one is not on screen at all. */
|
|
@@ -185,8 +190,24 @@ interface VerificationInputs {
|
|
|
185
190
|
onSetupIncomplete: () => void;
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Whether ANY activation flow can run for this provider: the card flow needs a
|
|
195
|
+
* card to charge, the redirect flow a hosted page to send the owner to. A
|
|
196
|
+
* PIX-only provider that answers with a code on this page (Itau) has neither,
|
|
197
|
+
* so the step could only ever fail with "the provider returned no payment
|
|
198
|
+
* link" — it is switched on once its credential probe passes instead, the rule
|
|
199
|
+
* `ProviderStatusBar`'s toggle already applies to a provider that declares no
|
|
200
|
+
* `activationCharge`. Every other provider keeps the step exactly as before.
|
|
201
|
+
*/
|
|
202
|
+
function activationRunnable(descriptor: { capabilities?: { methods?: readonly string[]; tokenization?: string } }): boolean {
|
|
203
|
+
const capabilities = descriptor.capabilities;
|
|
204
|
+
if (!capabilities?.methods) return true;
|
|
205
|
+
return capabilities.tokenization === 'REDIRECT' || capabilities.methods.includes('CARD');
|
|
206
|
+
}
|
|
207
|
+
|
|
188
208
|
/** The activation step's context, built from what this screen already knows. */
|
|
189
209
|
function verificationFor(io: VerificationInputs): ReactNode {
|
|
210
|
+
if (!io.provable) return null;
|
|
190
211
|
// `enabled` is deliberately NOT passed: the step used it to show a standing
|
|
191
212
|
// "provider is active" banner, which kept reassuring a store whose every
|
|
192
213
|
// charge was being refused. `proven` is a different kind of fact — it is
|
|
@@ -331,6 +352,7 @@ export function PaymentProviderSettings({
|
|
|
331
352
|
render: renderVerification,
|
|
332
353
|
provider: active.name,
|
|
333
354
|
displayName: active.displayName,
|
|
355
|
+
provable: activationRunnable(active),
|
|
334
356
|
config: activeConfig,
|
|
335
357
|
blocked: guideAwaitsConfirmation(guide, ack.confirmed, loaded),
|
|
336
358
|
// The walkthrough still has a step to show ⇒ this is not that step.
|
|
@@ -338,7 +338,7 @@ export function ProviderForm(props: ProviderFormProps) {
|
|
|
338
338
|
/>
|
|
339
339
|
) : null;
|
|
340
340
|
|
|
341
|
-
const fields = summary ? null : <CredentialFields descriptor={descriptor} form={form} proven={Boolean(config?.chargeVerifiedAt)} />;
|
|
341
|
+
const fields = summary ? null : <CredentialFields descriptor={descriptor} environment={props.environment} form={form} proven={Boolean(config?.chargeVerifiedAt)} />;
|
|
342
342
|
|
|
343
343
|
return (
|
|
344
344
|
<Stack spacing={2}>
|
|
@@ -206,15 +206,18 @@ function StepRow({ step, actions }: StepRowProps) {
|
|
|
206
206
|
// A bordered row with the work on the left and the way to do it on the
|
|
207
207
|
// right, so a step reads as a thing to tick off rather than as a paragraph.
|
|
208
208
|
// The instructions on this screen are a CHECKLIST — each one is a piece of
|
|
209
|
-
// work the owner does somewhere else and comes back from.
|
|
209
|
+
// work the owner does somewhere else and comes back from. On a phone the
|
|
210
|
+
// row wraps: the text keeps a readable measure and the button drops under
|
|
211
|
+
// it, rather than squeezing the text to a word per line beside it.
|
|
210
212
|
return (
|
|
211
213
|
<Stack
|
|
212
214
|
direction="row"
|
|
213
215
|
gap="12px"
|
|
214
216
|
alignItems="flex-start"
|
|
217
|
+
flexWrap="wrap"
|
|
215
218
|
sx={{ border: `1px solid ${T.line}`, borderRadius: '9px', px: '14px', py: '12px' }}
|
|
216
219
|
>
|
|
217
|
-
<Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
|
|
220
|
+
<Stack spacing={1} sx={{ flex: '1 1 240px', minWidth: 0 }}>
|
|
218
221
|
<StepText text={step.text} link={step.link} />
|
|
219
222
|
{action ? (
|
|
220
223
|
<Box>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* which decides whether the buyer is asked PIX-or-card here at all. It lived
|
|
8
8
|
* inside `capability-default.tsx` while there was one caller.
|
|
9
9
|
*/
|
|
10
|
-
import type { CheckoutProviderConfig } from "../types";
|
|
10
|
+
import type { CheckoutChainLink, CheckoutProviderConfig } from "../types";
|
|
11
11
|
|
|
12
12
|
/** Schemes that give the BROWSER a card form of its own. */
|
|
13
13
|
const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
|
|
@@ -30,7 +30,8 @@ const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK
|
|
|
30
30
|
* hosted — this is the FUT-747 correction, and getting it wrong routed the
|
|
31
31
|
* simplest store there is (one PIX-only provider honestly declaring
|
|
32
32
|
* `NONE`) into a hand-off it had no link for.
|
|
33
|
-
* - Hosted only when NOBODY who takes a card takes it here
|
|
33
|
+
* - Hosted only when NOBODY who takes a card takes it here — and nobody
|
|
34
|
+
* takes PIX here either (see {@link takesPixOnThisPage}).
|
|
34
35
|
* - No chain served (an older host, a still-loading config) ⇒ not hosted,
|
|
35
36
|
* which is what this checkout did before there was a chain to read.
|
|
36
37
|
*/
|
|
@@ -39,5 +40,24 @@ export function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
|
|
|
39
40
|
if (!chain || chain.length === 0) return false;
|
|
40
41
|
const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
|
|
41
42
|
if (cardCapable.length === 0) return false;
|
|
43
|
+
if (chain.some(takesPixOnThisPage)) return false;
|
|
42
44
|
return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
|
|
43
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A provider that answers PIX with a code for THIS page rather than a link to
|
|
49
|
+
* its own. Whenever the chain holds one, the store is not a hand-off: PIX is
|
|
50
|
+
* paid here, so the picker is ours to show, and a card that only a hosted
|
|
51
|
+
* provider takes still leaves through `handOverToProvider` the moment its
|
|
52
|
+
* order comes back with a link. Without this rule a PIX-only on-page provider
|
|
53
|
+
* ahead of a hosted card provider (Itau, then InfinitePay) was routed whole to
|
|
54
|
+
* the hand-off screen, which never paints the QR and never offers the card.
|
|
55
|
+
*
|
|
56
|
+
* `NONE` is read as "no browser step" here. An adapter that declares `NONE`
|
|
57
|
+
* for a HOSTED checkout that also takes PIX (legal, though none ships) must
|
|
58
|
+
* declare `checkoutScreen: 'hosted-link'`, which `methodChosenAtProvider`
|
|
59
|
+
* honours before this rule is ever consulted.
|
|
60
|
+
*/
|
|
61
|
+
function takesPixOnThisPage(link: CheckoutChainLink): boolean {
|
|
62
|
+
return link.methods.includes("PIX") && link.tokenization !== "REDIRECT";
|
|
63
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
Typography,
|
|
23
23
|
} from '@mui/material';
|
|
24
24
|
import { useState } from 'react';
|
|
25
|
+
import QRCode from 'react-qr-code';
|
|
25
26
|
|
|
26
27
|
import type { ClientChargeView, Money } from '@12-apps/payments-backend';
|
|
27
28
|
|
|
@@ -55,9 +56,22 @@ export function PixPanel({ charge, copy }: { charge: ClientChargeView; copy: Leg
|
|
|
55
56
|
if (!charge.pix) return null;
|
|
56
57
|
return (
|
|
57
58
|
<Stack spacing={1} alignItems="flex-start">
|
|
59
|
+
{/* The provider's own image when it sent one (Stone, Stripe); otherwise
|
|
60
|
+
drawn here from the BR Code, as `PixView` does — a provider that
|
|
61
|
+
returns only the code (PagBank, Itau) must still give the buyer
|
|
62
|
+
something to scan. */}
|
|
58
63
|
{charge.pix.qrImageUrl ? (
|
|
59
64
|
<Box component="img" src={charge.pix.qrImageUrl} alt={copy.qrAlt} sx={{ width: 220 }} />
|
|
60
|
-
) :
|
|
65
|
+
) : (
|
|
66
|
+
<Box
|
|
67
|
+
role="img"
|
|
68
|
+
aria-label={copy.qrAlt}
|
|
69
|
+
data-testid="payments-pix-qr-local"
|
|
70
|
+
sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
|
71
|
+
>
|
|
72
|
+
<QRCode value={charge.pix.qrText} size={200} />
|
|
73
|
+
</Box>
|
|
74
|
+
)}
|
|
61
75
|
<TextField fullWidth multiline size="small" label={copy.copyPasteLabel} value={charge.pix.qrText} />
|
|
62
76
|
<Button
|
|
63
77
|
size="small"
|
|
@@ -18,6 +18,11 @@ import type { CredentialFormCopy } from './settings-copy';
|
|
|
18
18
|
* they can be read as the set of rules they are.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
/** Whether a field exists in `environment` — see `CredentialFieldSpec.environments`. */
|
|
22
|
+
export function appliesIn(spec: CredentialFieldSpec, environment: PaymentEnvironment): boolean {
|
|
23
|
+
return !spec.environments || spec.environments.includes(environment);
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
/**
|
|
22
27
|
* Is there enough on record to make the probe WORTH RUNNING?
|
|
23
28
|
*
|
|
@@ -44,7 +49,7 @@ export function credentialsComplete(
|
|
|
44
49
|
): boolean {
|
|
45
50
|
const stored = config?.environments[environment] ?? {};
|
|
46
51
|
return descriptor.credentialSchema.every((spec) => {
|
|
47
|
-
if (spec.advanced) return true;
|
|
52
|
+
if (spec.advanced || !appliesIn(spec, environment)) return true;
|
|
48
53
|
const typed = values[spec.key];
|
|
49
54
|
if (typed !== undefined && typed.trim() !== '') return true;
|
|
50
55
|
return stored[spec.key]?.configured === true;
|