@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.27.2",
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.27.1",
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 ? { htmlInput: { sx: MONO_INPUT, spellCheck: false } } : { htmlInput: {} }
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
- ) : null}
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;