@12-apps/payments-frontend 1.12.0 → 1.13.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": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "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.",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "storybook:build": "storybook build"
18
18
  },
19
19
  "dependencies": {
20
- "@12-apps/payments-backend": "^1.12.0",
20
+ "@12-apps/payments-backend": "^1.13.0",
21
21
  "react-qr-code": "^2.2.0"
22
22
  },
23
23
  "peerDependencies": {
@@ -28,8 +28,8 @@
28
28
  "react-dom": ">=19.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@12-apps/eslint-config": "^1.13.0",
32
- "@12-apps/typescript-config": "^1.13.0",
31
+ "@12-apps/eslint-config": "^1.15.0",
32
+ "@12-apps/typescript-config": "^1.15.0",
33
33
  "@emotion/react": "^11.14.0",
34
34
  "@emotion/styled": "^11.14.0",
35
35
  "@mui/material": "^6.5.0",
@@ -2,12 +2,9 @@ import { Box } from "@mui/material";
2
2
  import { useEffect, useRef, type JSX, type ReactNode } from "react";
3
3
 
4
4
  import { BuyerInfoForm } from "./buyer-info-form";
5
- import { CardView } from "./card-view";
6
5
  import { LockOutlinedIcon } from "./icons";
7
6
  import {
8
- cardChain,
9
7
  cardPathAvailable,
10
- cardTokenization,
11
8
  offeredMethods,
12
9
  selectableMethods,
13
10
  usePreselectSoleMethod,
@@ -15,7 +12,7 @@ import {
15
12
  import { MethodPicker } from "./method-picker";
16
13
  import { PaymentErrorPanel } from "./payment-error-panel";
17
14
  import { PayerSummary } from "./payer-summary";
18
- import { PixView } from "./pix-view";
15
+ import { resolveCheckoutScreen } from "./providers/registry";
19
16
  import type {
20
17
  BuyerField,
21
18
  BuyerInfo,
@@ -53,11 +50,24 @@ function useAutoRaiseOrder(
53
50
  }, [method, order, creating, createError, onGenerate]);
54
51
  }
55
52
 
56
- /** The per-method payment body — PIX QR, card form, or nothing until raised. */
53
+ /**
54
+ * The payment body — whichever screen the store's provider declares (FUT-596).
55
+ *
56
+ * This used to switch on `order.method` and compose the PIX and card panes
57
+ * itself. It now resolves a screen from the id the adapter published on the
58
+ * chain's head and renders it; the panes moved to `providers/`, unchanged.
59
+ * The shell keeps everything shared — picker, payer, totals, errors, the
60
+ * polling cadence — so a provider's flow differs only where it genuinely does.
61
+ *
62
+ * `resolveCheckoutScreen` always returns a component, so there is no branch
63
+ * here for "no screen": an undeclared or unrecognised id lands on the
64
+ * capability default.
65
+ */
57
66
  function PaymentBody({
58
67
  order,
59
68
  buyer,
60
69
  providerConfig,
70
+ method,
61
71
  tenantSlug,
62
72
  onResolved,
63
73
  pollIntervalMs,
@@ -65,29 +75,23 @@ function PaymentBody({
65
75
  order: CheckoutOrder | null;
66
76
  buyer: BuyerInfo;
67
77
  providerConfig: CheckoutProviderConfig | null;
78
+ method: PaymentMethod | null;
68
79
  tenantSlug?: string;
69
80
  onResolved: (status: OrderStatus) => void;
70
81
  pollIntervalMs?: number;
71
82
  }): JSX.Element | null {
72
- if (order?.method === "PIX") {
73
- return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
74
- }
75
- if (order?.method === "CARD") {
76
- return (
77
- <CardView
78
- order={order}
79
- buyer={buyer}
80
- providerConfig={cardTokenization(providerConfig)}
81
- // The whole chain (FUT-563): one instrument is minted per provider so
82
- // the charge survives the first one failing, with nothing re-typed.
83
- providerChain={cardChain(providerConfig)}
84
- tenantSlug={tenantSlug}
85
- onResolved={onResolved}
86
- pollIntervalMs={pollIntervalMs}
87
- />
88
- );
89
- }
90
- return null;
83
+ const Screen = resolveCheckoutScreen(providerConfig?.chain?.[0]?.checkoutScreen);
84
+ return (
85
+ <Screen
86
+ order={order}
87
+ buyer={buyer}
88
+ config={providerConfig}
89
+ method={method}
90
+ tenantSlug={tenantSlug}
91
+ onResolved={onResolved}
92
+ pollIntervalMs={pollIntervalMs}
93
+ />
94
+ );
91
95
  }
92
96
 
93
97
  /** Empty-cart state shown when there's nothing to check out. */
@@ -334,6 +338,7 @@ export function PaymentStep({
334
338
  order={order}
335
339
  buyer={buyer}
336
340
  providerConfig={providerConfig ?? null}
341
+ method={method}
337
342
  tenantSlug={tenantSlug}
338
343
  onResolved={onResolved}
339
344
  pollIntervalMs={pollIntervalMs}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The screen for a provider that declares none (FUT-596, AC3).
3
+ *
4
+ * This is the guarantee that adding a vendor never leaves a blank pane. It is
5
+ * reached three ways, and all three are normal rather than exceptional:
6
+ *
7
+ * 1. An adapter that has not declared `checkoutScreen` at all — Stone and
8
+ * Stripe today. They check out on the day they are written.
9
+ * 2. A host serving an older `/checkout/config` with no chain, so there is
10
+ * no id to read.
11
+ * 3. An id THIS bundle has never heard of. The backend and frontend packages
12
+ * version independently, so a host running a newer server than bundle is
13
+ * an ordinary deployment state — and the buyer must not pay for it.
14
+ *
15
+ * It does not reimplement a pane. It picks between the same two screens a
16
+ * declaration would have chosen, from the capabilities every adapter already
17
+ * publishes — so the default and the declared path cannot drift apart, and
18
+ * there is exactly one implementation of each shape.
19
+ */
20
+ import type { JSX } from "react";
21
+
22
+ import type { CheckoutProviderConfig } from "../types";
23
+
24
+ import { HostedLinkScreen } from "./hosted-link";
25
+ import { PixAndCardScreen } from "./pix-and-card";
26
+ import type { ProviderCheckoutScreenProps } from "./types";
27
+
28
+ /** Schemes that give the BROWSER a card form of its own. */
29
+ const IN_BROWSER_TOKENIZATION: ReadonlySet<string> = new Set(["PUBLIC_KEY", "SDK"]);
30
+
31
+ /**
32
+ * Whether this store hands the buyer over instead of collecting here — the
33
+ * frontend twin of the server's `usesHostedCheckout`, deliberately written to
34
+ * the same three rules so the pane and the walk cannot disagree.
35
+ *
36
+ * Note this is NOT `!cardPathAvailable(config)`. That helper answers a
37
+ * different question — "is a card offerable at all" — and it answers TRUE for
38
+ * a hand-off store, because typing the card on the provider's page is still a
39
+ * card path. Inverting it therefore sends the hosted store to the on-page
40
+ * screen and the on-page store to the hand-off, which is exactly backwards.
41
+ *
42
+ * The rules, in order:
43
+ * - Only CARD can be answered in advance. TOKENIZATION IS A CARD FACT: it
44
+ * says how the browser turns a PAN into an instrument, and a PIX charge
45
+ * has no instrument to mint. A store with no card-capable entry is not
46
+ * hosted — this is the FUT-747 correction, and getting it wrong routed the
47
+ * simplest store there is (one PIX-only provider honestly declaring
48
+ * `NONE`) into a hand-off it had no link for.
49
+ * - Hosted only when NOBODY who takes a card takes it here.
50
+ * - No chain served (an older host, a still-loading config) ⇒ not hosted,
51
+ * which is what this checkout did before there was a chain to read.
52
+ */
53
+ function handsBuyerOver(config: CheckoutProviderConfig | null): boolean {
54
+ const chain = config?.chain;
55
+ if (!chain || chain.length === 0) return false;
56
+ const cardCapable = chain.filter((link) => link.methods.includes("CARD"));
57
+ if (cardCapable.length === 0) return false;
58
+ return !cardCapable.some((link) => IN_BROWSER_TOKENIZATION.has(link.tokenization));
59
+ }
60
+
61
+ export function CapabilityDefaultScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
62
+ return handsBuyerOver(props.config) ? (
63
+ <HostedLinkScreen {...props} />
64
+ ) : (
65
+ <PixAndCardScreen {...props} />
66
+ );
67
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The screen for a store whose buyer finishes on the PROVIDER's page (FUT-596).
3
+ *
4
+ * Declared as `hosted-link` by any adapter of that shape — today InfinitePay.
5
+ * There is no card form and no PIX pane here on purpose: this provider takes
6
+ * neither on our page, and rendering either would offer a buyer something the
7
+ * charge cannot honour.
8
+ *
9
+ * ## Why this screen renders no payable
10
+ *
11
+ * For a hand-off provider the pane never receives an `order`, and that is by
12
+ * design rather than an omission. The controller raises the charge, sees a
13
+ * `hostedCheckoutUrl` on the response and navigates — `handOverToProvider`
14
+ * returns before `setOrder` (`use-checkout-controller.ts`), because the buyer
15
+ * is leaving this page and parking the payable for the return leg matters more
16
+ * than painting a pane they will not see. `hosted-return.ts` picks it back up.
17
+ *
18
+ * So the whole job here is the moment BEFORE that: the buyer has chosen a
19
+ * method and the redirect is being prepared. Previously the pane rendered
20
+ * `null` through that moment, which reads as a checkout that has stalled — the
21
+ * screen tells them where they are going instead. Nothing here touches the
22
+ * money path; the navigation and the parking are unchanged.
23
+ */
24
+ import { Box } from "@mui/material";
25
+ import type { JSX } from "react";
26
+
27
+ import { useCheckoutComponents } from "../ui";
28
+
29
+ import type { ProviderCheckoutScreenProps } from "./types";
30
+
31
+ export function HostedLinkScreen({ method }: ProviderCheckoutScreenProps): JSX.Element | null {
32
+ const { Text, LoadingState } = useCheckoutComponents();
33
+
34
+ // Nothing chosen yet ⇒ the shell is still showing the picker. Same as every
35
+ // other screen: the pane stays out of the way until there is something to say.
36
+ if (!method) return null;
37
+
38
+ return (
39
+ <Box
40
+ data-testid="checkout-handoff-pending"
41
+ sx={{ display: "flex", flexDirection: "column", gap: 2, alignItems: "center", py: 4 }}
42
+ >
43
+ <LoadingState variant="spinner" message="Preparando o pagamento" size="md" />
44
+ <Text variant="body" size="md" as="p">
45
+ Você será levado à página segura do provedor para concluir o pagamento.
46
+ </Text>
47
+ <Text variant="caption" size="sm" as="p" color="secondary">
48
+ Assim que terminar, você volta para cá e nós confirmamos o pedido.
49
+ </Text>
50
+ </Box>
51
+ );
52
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The screen for a store that collects payment ON OUR PAGE (FUT-596).
3
+ *
4
+ * A PIX charge renders the provider's code here; a card charge renders our own
5
+ * form and mints the instrument in this browser. Declared by any adapter whose
6
+ * flow is that shape — `pix-and-card` — which today is PagBank and tomorrow is
7
+ * Stone, from one declaration each and no second component.
8
+ *
9
+ * It carries NO hand-off branch. A provider that finishes on its own page
10
+ * declares `hosted-link` and renders `./hosted-link.tsx` instead; the two never
11
+ * test for each other, which is the property FUT-596 exists to buy.
12
+ */
13
+ import type { JSX } from "react";
14
+
15
+ import { CardView } from "../card-view";
16
+ import { cardChain, cardTokenization } from "../method-capability";
17
+ import { PixView } from "../pix-view";
18
+
19
+ import type { ProviderCheckoutScreenProps } from "./types";
20
+
21
+ export function PixAndCardScreen({
22
+ order,
23
+ buyer,
24
+ config,
25
+ tenantSlug,
26
+ onResolved,
27
+ pollIntervalMs,
28
+ }: ProviderCheckoutScreenProps): JSX.Element | null {
29
+ if (order?.method === "PIX") {
30
+ return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
31
+ }
32
+ if (order?.method === "CARD") {
33
+ return (
34
+ <CardView
35
+ order={order}
36
+ buyer={buyer}
37
+ providerConfig={cardTokenization(config)}
38
+ // The whole chain (FUT-563): one instrument is minted per provider so
39
+ // the charge survives the first one failing, with nothing re-typed.
40
+ providerChain={cardChain(config)}
41
+ tenantSlug={tenantSlug}
42
+ onResolved={onResolved}
43
+ pollIntervalMs={pollIntervalMs}
44
+ />
45
+ );
46
+ }
47
+ // No order yet — the shell is still showing the picker, and raises one as
48
+ // soon as a method is chosen.
49
+ return null;
50
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Screen id → screen (FUT-596).
3
+ *
4
+ * The one place the buyer's pane is chosen. An adapter declares
5
+ * `checkoutScreen` (see `PaymentProviderAdapter`), the gateway publishes it on
6
+ * every chain entry, and this table turns it into a component.
7
+ *
8
+ * ## Why the key is a screen id and not a provider name
9
+ *
10
+ * A provider-name switch is the shape this ticket exists to remove. It cannot
11
+ * express reuse — Stone's flow is PagBank's flow, and under a name key that is
12
+ * a duplicated entry rather than one declaration — and it puts vendor
13
+ * knowledge in the frontend, where nothing declares it and no adapter outside
14
+ * this package can extend it. A screen id inverts that: the adapter says what
15
+ * shape its flow is, and any number of providers share one screen by saying
16
+ * the same word. It is the contract `SetupStep.action` already uses one layer
17
+ * up, for the same reason.
18
+ *
19
+ * ## Unknown ids resolve, they do not fail
20
+ *
21
+ * {@link screenFor} returns `null` for an id it does not know, and the caller
22
+ * renders {@link CapabilityDefaultScreen}. This is load-bearing: the backend
23
+ * and frontend packages version independently, so a host running a newer
24
+ * server than bundle will publish ids this table has never seen. That is an
25
+ * ordinary deployment state and it must degrade to a working checkout, never
26
+ * to an empty pane.
27
+ */
28
+ import { CapabilityDefaultScreen } from "./capability-default";
29
+ import { HostedLinkScreen } from "./hosted-link";
30
+ import { PixAndCardScreen } from "./pix-and-card";
31
+ import type { ProviderCheckoutScreen } from "./types";
32
+
33
+ /**
34
+ * Every screen this bundle can render, by the id an adapter declares.
35
+ *
36
+ * NULL-PROTOTYPE on purpose. The key is a string that arrives from the server,
37
+ * so a plain object literal would answer `SCREENS['constructor']` with
38
+ * `Object` and `SCREENS['toString']` with a function — both truthy, neither a
39
+ * component, and the pane would throw mid-render for a store whose declared id
40
+ * happened to collide with `Object.prototype`. `Object.create(null)` has no
41
+ * prototype to inherit from, so the only keys are the ones written here.
42
+ */
43
+ const SCREENS: Readonly<Record<string, ProviderCheckoutScreen>> = Object.assign(
44
+ Object.create(null) as Record<string, ProviderCheckoutScreen>,
45
+ {
46
+ /** Collected on our page: a PIX code, or a card typed here. */
47
+ "pix-and-card": PixAndCardScreen,
48
+ /** Finished on the provider's own page. */
49
+ "hosted-link": HostedLinkScreen,
50
+ },
51
+ );
52
+
53
+ /**
54
+ * The screen for a declared id, or `null` when nothing is declared or the id
55
+ * is unknown to this bundle. A `null` answer is not an error — see the module
56
+ * comment.
57
+ */
58
+ export function screenFor(id: string | null | undefined): ProviderCheckoutScreen | null {
59
+ if (!id) return null;
60
+ return SCREENS[id] ?? null;
61
+ }
62
+
63
+ /**
64
+ * THE resolution the pane uses: the declared screen, else the capability
65
+ * default. Always returns something renderable, which is AC3.
66
+ *
67
+ * The id is read from the chain's HEAD, because the head is the provider the
68
+ * walk tries first and therefore the flow the buyer is about to enter. A
69
+ * failover to a tail provider happens server-side, after the buyer has already
70
+ * given us everything the charge needs — the card is typed once and minted for
71
+ * every chain entry (FUT-563) — so the pane does not change under them
72
+ * mid-charge, and must not.
73
+ */
74
+ export function resolveCheckoutScreen(
75
+ chainHeadScreen: string | null | undefined,
76
+ ): ProviderCheckoutScreen {
77
+ return screenFor(chainHeadScreen) ?? CapabilityDefaultScreen;
78
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The buyer checkout SCREEN contract (FUT-596).
3
+ *
4
+ * A screen owns the pane where the buyer actually pays — the PIX code, the card
5
+ * form, the hand-off notice — and nothing else. Everything shared stays in the
6
+ * shell: the stepper, the payer summary, the method picker, the totals, the
7
+ * error surfaces, the polling cadence and the terminal `onResolved` hop to
8
+ * Confirmação. That split is what lets a vendor's flow differ in the one place
9
+ * it genuinely differs without forking a checkout.
10
+ *
11
+ * Which screen renders is resolved from an id the ADAPTER declares
12
+ * (`PaymentProviderAdapter.checkoutScreen`, published per chain entry), so
13
+ * neither the shell nor any host names a provider to get here. See
14
+ * `./registry.ts`.
15
+ */
16
+ import type { JSX } from "react";
17
+
18
+ import type {
19
+ BuyerInfo,
20
+ CheckoutOrder,
21
+ CheckoutProviderConfig,
22
+ OrderStatus,
23
+ PaymentMethod,
24
+ } from "../types";
25
+
26
+ /**
27
+ * What every screen is handed. Every field is already a prop of `PaymentStep`,
28
+ * so resolving a screen threads nothing new through the shell.
29
+ */
30
+ export interface ProviderCheckoutScreenProps {
31
+ /**
32
+ * The raised payable, or `null` before the buyer has chosen a method.
33
+ *
34
+ * `null` is a real state a screen must render, not a loading artefact: it is
35
+ * the moment a hand-off screen says where the buyer is about to be sent.
36
+ */
37
+ order: CheckoutOrder | null;
38
+ buyer: BuyerInfo;
39
+ /**
40
+ * The store's protocol, RAW — not pre-narrowed to the card path's view.
41
+ *
42
+ * The shell used to hand the pane `cardTokenization(config)` and
43
+ * `cardChain(config)` directly. Both are card-shaped readings, and a screen
44
+ * whose provider takes no card here must not be given one: it would describe
45
+ * a store that mints instruments when this one does not. Screens that need
46
+ * those readings call the helpers themselves.
47
+ */
48
+ config: CheckoutProviderConfig | null;
49
+ /** What the shell's picker currently has selected; `null` before a choice. */
50
+ method: PaymentMethod | null;
51
+ /** Scopes the saved-card list to the store being paid. */
52
+ tenantSlug?: string;
53
+ /** The shell's polling cadence, passed through so tests can shorten it. */
54
+ pollIntervalMs?: number;
55
+ /** A terminal status — the shell moves to Confirmação. */
56
+ onResolved: (status: OrderStatus) => void;
57
+ }
58
+
59
+ /**
60
+ * A screen is a plain component. `null` is a legal render (nothing to show
61
+ * yet), which is what the pane did for every provider before FUT-596.
62
+ */
63
+ export type ProviderCheckoutScreen = (
64
+ props: ProviderCheckoutScreenProps,
65
+ ) => JSX.Element | null;
@@ -236,6 +236,22 @@ export interface CheckoutChainLink {
236
236
  * never shown a field for.
237
237
  */
238
238
  customerSchema?: CheckoutCustomerField[];
239
+ /**
240
+ * The buyer screen THIS provider's flow needs (FUT-596) — an opaque id the
241
+ * adapter declares and `providers/registry.ts` resolves to a component.
242
+ *
243
+ * Optional, and the DEGRADE DIRECTION IS THE OPPOSITE of `customerSchema`'s
244
+ * above — spelled out because a reader will otherwise assume symmetry from
245
+ * the neighbouring comment. Absent, `null`, or an id THIS bundle has never
246
+ * heard of all mean the CAPABILITY DEFAULT: the pane composed from
247
+ * `tokenization` + `methods`, exactly as it was before this field existed.
248
+ *
249
+ * That is the safe direction here. Treating an unknown id as "render
250
+ * nothing" would blank the pane for every buyer of a store whose backend is
251
+ * one release ahead of this bundle — and the two packages version
252
+ * independently, so that skew is normal, not exceptional.
253
+ */
254
+ checkoutScreen?: string | null;
239
255
  }
240
256
 
241
257
  /**