@12-apps/payments-frontend 1.16.0 → 1.18.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.
@@ -140,6 +140,51 @@ function toCardLink(link: CheckoutChainLink): CardChainLink {
140
140
  };
141
141
  }
142
142
 
143
+ /**
144
+ * Everything the Google Pay button needs to mint a token (FUT-471), or `null`
145
+ * when the button must not render.
146
+ *
147
+ * Read off the chain HEAD only, like the checkout screen: the wallet token is
148
+ * minted against ONE gateway's `gatewayMerchantId`, so only the provider the
149
+ * walk tries first can charge it — a tail entry's declaration cannot be
150
+ * honoured from this browser.
151
+ *
152
+ * FAILS CLOSED, deliberately the opposite of `offeredMethods`' fail-open: a
153
+ * missing config only costs the buyer a button they still have the card form
154
+ * without, while rendering one on a store that cannot charge wallets sends a
155
+ * buyer through the wallet sheet into a guaranteed refusal. Every clause
156
+ * narrows toward NOT rendering: no config, no chain, a head that cannot CARD,
157
+ * no `GOOGLE_PAY` declaration, no gateway parameters, no merchant id.
158
+ */
159
+ export function googlePayConfig(
160
+ config: CheckoutProviderConfig | null,
161
+ ): { gateway: string; gatewayMerchantId: string } | null {
162
+ const head = config?.chain?.[0];
163
+ if (!head?.methods.includes("CARD")) return null;
164
+ if (!head.wallets?.includes("GOOGLE_PAY")) return null;
165
+ const params = head.googlePay;
166
+ if (!params?.gatewayMerchantId) return null;
167
+ return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
168
+ }
169
+
170
+ /**
171
+ * Whether the chain head declares Apple Pay (FUT-472) — the capability half
172
+ * of the button's gate; the DEVICE half (`ApplePaySession` exists and can
173
+ * pay) is `applePaySupported()` in `apple-pay-button.tsx`, because it is a
174
+ * browser fact, not a store fact.
175
+ *
176
+ * FAILS CLOSED like {@link googlePayConfig}, and read off the HEAD for the
177
+ * same reason: the Apple token is decrypted with the head merchant's
178
+ * certificate, so only the provider the walk tries first can charge it.
179
+ * Unlike Google there are no client parameters to publish — merchant
180
+ * validation runs server-side through the host's port.
181
+ */
182
+ export function applePayDeclared(config: CheckoutProviderConfig | null): boolean {
183
+ const head = config?.chain?.[0];
184
+ if (!head?.methods.includes("CARD")) return false;
185
+ return head.wallets?.includes("APPLE_PAY") ?? false;
186
+ }
187
+
143
188
  /**
144
189
  * The methods the picker may offer, from the chain's declared capabilities
145
190
  * (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
@@ -12,37 +12,22 @@
12
12
  */
13
13
  import type { JSX } from "react";
14
14
 
15
- import { CardView } from "../card-view";
16
- import { cardChain, cardTokenization } from "../method-capability";
17
15
  import { PixView } from "../pix-view";
16
+ import { WalletCardPane } from "../wallet-pane";
18
17
 
19
18
  import type { ProviderCheckoutScreenProps } from "./types";
20
19
 
21
- export function PixAndCardScreen({
22
- order,
23
- buyer,
24
- config,
25
- tenantSlug,
26
- onResolved,
27
- pollIntervalMs,
28
- }: ProviderCheckoutScreenProps): JSX.Element | null {
20
+ export function PixAndCardScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
21
+ const { order, onResolved, pollIntervalMs } = props;
29
22
  if (order?.method === "PIX") {
30
23
  return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
31
24
  }
32
25
  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
- );
26
+ // The card pane, with its wallet fast lane above the form (FUT-471/472).
27
+ // The pane reads the chain itself (FUT-563: one instrument per provider,
28
+ // so the charge survives the first one failing with nothing re-typed) and
29
+ // renders exactly the old CardView for a store with no wallet.
30
+ return <WalletCardPane {...props} order={order} />;
46
31
  }
47
32
  // No order yet — the shell is still showing the picker, and raises one as
48
33
  // soon as a method is chosen.
@@ -52,6 +52,14 @@ export interface ProviderCheckoutScreenProps {
52
52
  tenantSlug?: string;
53
53
  /** The shell's polling cadence, passed through so tests can shorten it. */
54
54
  pollIntervalMs?: number;
55
+ /**
56
+ * The host's Apple Pay merchant-validation port (FUT-472): exchange the
57
+ * session's `validationURL` for an Apple merchant session, SERVER-SIDE —
58
+ * the merchant identity certificate must never reach a browser. Optional;
59
+ * absent, the Apple Pay sheet cannot start and the pane says so while the
60
+ * card form stays the way to pay.
61
+ */
62
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
55
63
  /** A terminal status — the shell moves to Confirmação. */
56
64
  onResolved: (status: OrderStatus) => void;
57
65
  }
@@ -21,6 +21,7 @@ import { err, ok, type Result } from "../../result";
21
21
  import type {
22
22
  ChargeCardInput,
23
23
  ChargeOutcome,
24
+ ChargeWalletInput,
24
25
  CheckoutProviderConfig,
25
26
  OrderStatus,
26
27
  } from "./types";
@@ -45,11 +46,13 @@ export interface CheckoutTransport {
45
46
  headers?: () => HeadersInit | Promise<HeadersInit>;
46
47
  }
47
48
 
48
- /** The five calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
+ /** The six calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
49
50
  export interface CheckoutClient {
50
51
  getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
51
52
  getStatus(ref: string): Promise<Result<OrderStatus>>;
52
53
  charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
54
+ /** A wallet instrument against the same `/charge` route (FUT-471/472). */
55
+ chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
53
56
  listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
54
57
  refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
55
58
  }
@@ -112,6 +115,36 @@ function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Res
112
115
  return globalThis.fetch(input, init);
113
116
  }
114
117
 
118
+ /**
119
+ * The FLAT card-charge body the shipped client has always sent. Pinned from
120
+ * both ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
121
+ */
122
+ function flatChargeBody(input: ChargeCardInput): string {
123
+ return JSON.stringify({
124
+ orderId: input.orderId,
125
+ token: input.token,
126
+ // One instrument per provider (FUT-563) — the server hands each provider
127
+ // in the chain its own, which is what lets a card charge fail over.
128
+ ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
129
+ saveCard: input.saveCard,
130
+ cardMeta: input.cardMeta,
131
+ taxId: input.taxId,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The same flat wire with `wallet` in place of `token` (FUT-471): the mount's
137
+ * draft reader takes either, and a body naming both would carry two
138
+ * instruments for one charge. Pinned by `wallet-wire.contract.test.ts`.
139
+ */
140
+ function flatWalletBody(input: ChargeWalletInput): string {
141
+ return JSON.stringify({
142
+ orderId: input.orderId,
143
+ wallet: input.wallet,
144
+ taxId: input.taxId,
145
+ });
146
+ }
147
+
115
148
  /**
116
149
  * The five checkout calls, bound to one transport.
117
150
  *
@@ -156,22 +189,10 @@ export function createCheckoutClient(transport: CheckoutTransport = {}): Checkou
156
189
  ),
157
190
 
158
191
  charge: (input) =>
159
- call<ChargeOutcome>("/charge", {
160
- method: "POST",
161
- // The FLAT body the shipped client has always sent. Pinned from both
162
- // ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
163
- body: JSON.stringify({
164
- orderId: input.orderId,
165
- token: input.token,
166
- // One instrument per provider (FUT-563) — the server hands each
167
- // provider in the chain its own, which is what lets a card charge
168
- // fail over.
169
- ...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
170
- saveCard: input.saveCard,
171
- cardMeta: input.cardMeta,
172
- taxId: input.taxId,
173
- }),
174
- }),
192
+ call<ChargeOutcome>("/charge", { method: "POST", body: flatChargeBody(input) }),
193
+
194
+ chargeWallet: (input) =>
195
+ call<ChargeOutcome>("/charge", { method: "POST", body: flatWalletBody(input) }),
175
196
 
176
197
  listInstruments: async (tenantSlug) => {
177
198
  // Scoped to the store when known (FUT-697): only cards the store's ACTIVE
@@ -12,6 +12,14 @@
12
12
  /** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
13
13
  export type PaymentMethod = "PIX" | "CARD";
14
14
 
15
+ /**
16
+ * The digital wallets a chain entry can charge (FUT-471/472). Mirrors the
17
+ * backend's `WalletType` — a wallet is not a fourth method: it is another way
18
+ * of producing the CARD instrument, so it rides the CARD tile and the CARD
19
+ * charge.
20
+ */
21
+ export type CheckoutWalletType = "GOOGLE_PAY" | "APPLE_PAY";
22
+
15
23
  /**
16
24
  * Order lifecycle, aligned with the FUT-43 backend:
17
25
  * - `AWAITING_PAYMENT` — order created, charge raised, not yet reconciled (the
@@ -198,6 +206,20 @@ export interface ChargeCardInput {
198
206
  taxId?: string;
199
207
  }
200
208
 
209
+ /**
210
+ * Input to charge a WALLET instrument against an order (FUT-471/472) —
211
+ * `ChargeCardInput`'s wallet sibling, posted to the same `/charge` route. One
212
+ * shape for both wallets because the wire is one shape: `key` is whatever the
213
+ * wallet handed the browser (Google's `tokenizationData.token`, Apple's
214
+ * serialized `token.paymentData`), forwarded verbatim and never persisted.
215
+ */
216
+ export interface ChargeWalletInput {
217
+ orderId: string;
218
+ wallet: { type: CheckoutWalletType; key: string };
219
+ /** Buyer CPF for the provider charge (`customer.tax_id`); never persisted. */
220
+ taxId?: string;
221
+ }
222
+
201
223
  /** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
202
224
  export interface CheckoutCustomerField {
203
225
  key: "name" | "email" | "taxId" | "phone";
@@ -236,6 +258,25 @@ export interface CheckoutChainLink {
236
258
  * never shown a field for.
237
259
  */
238
260
  customerSchema?: CheckoutCustomerField[];
261
+ /**
262
+ * The digital wallets THIS entry's card charge accepts (FUT-471/472).
263
+ *
264
+ * Optional, and the DEGRADE DIRECTION IS CLOSED — the opposite of
265
+ * `customerSchema`'s above, because the stakes invert: a chain that asks for
266
+ * nothing produces a refused charge, but a wallet button on a store whose
267
+ * server cannot charge wallets produces a buyer who authorized a payment on
268
+ * the wallet sheet and was then refused. Absent (an older host) means NO
269
+ * wallet buttons, which is exactly what that host's charge route supports.
270
+ */
271
+ wallets?: CheckoutWalletType[];
272
+ /**
273
+ * Google Pay's `PAYMENT_GATEWAY` tokenizationSpecification parameters for
274
+ * THIS entry (FUT-471), or absent/`null` when it cannot run Google Pay.
275
+ * `gatewayMerchantId: null` means the connection carries no merchant id yet
276
+ * — the button must not render, because a token minted against a missing id
277
+ * charges nobody.
278
+ */
279
+ googlePay?: { gateway: string; gatewayMerchantId: string | null } | null;
239
280
  /**
240
281
  * The buyer screen THIS provider's flow needs (FUT-596) — an opaque id the
241
282
  * adapter declares and `providers/registry.ts` resolves to a component.
@@ -0,0 +1,129 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { useCheckoutClientApi } from "./client-context";
4
+ import { UNRESOLVED_CODE } from "./failure-codes";
5
+ import { rememberHostedOrder } from "./hosted-return";
6
+ import { useCheckoutNavigate } from "./navigate-context";
7
+ import type { BuyerInfo, CheckoutOrder, CheckoutWalletType, OrderStatus } from "./types";
8
+ import { usePaymentPolling } from "./use-payment-polling";
9
+
10
+ /**
11
+ * The submit half of a WALLET payment (FUT-471/472) — `useCardSubmit`'s
12
+ * sibling, minus everything a wallet does not have: no form to validate, no
13
+ * instrument to mint per provider (the wallet key is chain-head-bound, see the
14
+ * backend's `core/card-instrument.ts`), no vault opt-in. What remains is the
15
+ * same money path: charge → classify the outcome → poll for the async
16
+ * confirmation → bubble the terminal status up.
17
+ *
18
+ * One hook for BOTH wallets, because the wire is one shape — the buttons only
19
+ * differ in how they acquire the key.
20
+ */
21
+
22
+ /**
23
+ * Where the wallet payment stands. `idle` renders the buttons and the card
24
+ * form; anything else replaces them — a live "pay" control under a charge that
25
+ * may already be holding the buyer's money is the double-payment invitation
26
+ * the card view already refuses to render.
27
+ */
28
+ export type WalletPhase = "idle" | "charging" | "polling";
29
+
30
+ /** Everything a wallet pane renders — state plus the one submit entry point. */
31
+ export interface WalletCharge {
32
+ phase: WalletPhase;
33
+ /** The refusal to show above the form, when the charge came back refused. */
34
+ error: string | null;
35
+ /** The refusal's machine code — an UNRESOLVED charge is not a decline. */
36
+ errorCode: string | null;
37
+ /** The charge is unresolved: no pay control may render (FUT-563). */
38
+ unresolved: boolean;
39
+ pollError: string | null;
40
+ /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
41
+ pollTimedOut: boolean;
42
+ /**
43
+ * Charge the wallet's key. The button calls this once the sheet resolves.
44
+ * Resolves `true` when the charge was ACCEPTED — paid, confirming, or
45
+ * handed to the provider's page — and `false` on a refusal or decline, so a
46
+ * sheet that must be completed with a status (Apple's) can be honest.
47
+ */
48
+ payWithKey(type: CheckoutWalletType, key: string): Promise<boolean>;
49
+ }
50
+
51
+ /**
52
+ * Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
53
+ * (FUT-191): 36 polls ≈ 90 s at the 2500 ms default interval.
54
+ */
55
+ const WALLET_AWAITING_POLL_CAP = 36;
56
+
57
+ /** The wallet charge state machine. See the module comment. */
58
+ export function useWalletCharge(
59
+ order: CheckoutOrder,
60
+ buyer: BuyerInfo,
61
+ onResolved: (status: OrderStatus) => void,
62
+ pollIntervalMs = 2500,
63
+ ): WalletCharge {
64
+ const [phase, setPhase] = useState<WalletPhase>("idle");
65
+ const [error, setError] = useState<string | null>(null);
66
+ const [errorCode, setErrorCode] = useState<string | null>(null);
67
+ const client = useCheckoutClientApi();
68
+ const navigate = useCheckoutNavigate();
69
+
70
+ const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
71
+ enabled: phase === "polling",
72
+ intervalMs: pollIntervalMs,
73
+ maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
74
+ });
75
+
76
+ useEffect(() => {
77
+ if (status && status !== "AWAITING_PAYMENT") onResolved(status);
78
+ }, [status, onResolved]);
79
+
80
+ const payWithKey = async (type: CheckoutWalletType, key: string): Promise<boolean> => {
81
+ setError(null);
82
+ setErrorCode(null);
83
+ setPhase("charging");
84
+
85
+ const charged = await client.chargeWallet({
86
+ orderId: order.orderId,
87
+ wallet: { type, key },
88
+ // The CPF the Dados step collected — the provider's required-field gate
89
+ // reads it from the charge, the payable row has nowhere to keep it.
90
+ taxId: buyer.taxId,
91
+ });
92
+ if (!charged.ok) {
93
+ setError(charged.error);
94
+ setErrorCode(charged.code ?? null);
95
+ // Back to idle — but an UNRESOLVED code sets `unresolved`, and the pane
96
+ // reads that as "render NO pay control" (FUT-563): some provider may be
97
+ // holding the money, and a live button under "não pague de novo" is what
98
+ // the buyer's thumb reaches for.
99
+ setPhase("idle");
100
+ return false;
101
+ }
102
+ // A provider that demands its own page to finish (redirect 3-D Secure,
103
+ // FUT-698): park the order and hand the buyer over, exactly as the card
104
+ // path does. `phase` stays as-is — the tab is navigating away.
105
+ if (charged.data.hostedCheckoutUrl) {
106
+ rememberHostedOrder(order);
107
+ navigate(charged.data.hostedCheckoutUrl);
108
+ return true;
109
+ }
110
+ // A business outcome (declined → FAILED) shows the status screen; an
111
+ // accepted charge begins polling for the async confirmation.
112
+ if (charged.data.status !== "AWAITING_PAYMENT") {
113
+ onResolved(charged.data.status);
114
+ return charged.data.status === "PAID";
115
+ }
116
+ setPhase("polling");
117
+ return true;
118
+ };
119
+
120
+ return {
121
+ phase,
122
+ error,
123
+ errorCode,
124
+ unresolved: errorCode === UNRESOLVED_CODE,
125
+ pollError,
126
+ pollTimedOut,
127
+ payWithKey,
128
+ };
129
+ }
@@ -0,0 +1,187 @@
1
+ import { Box, Divider } from "@mui/material";
2
+ import { useState, type JSX } from "react";
3
+
4
+ import { ApplePayButton, applePaySupported } from "./apple-pay-button";
5
+ import { CardView } from "./card-view";
6
+ import { GooglePayButton } from "./google-pay-button";
7
+ import {
8
+ applePayDeclared,
9
+ cardChain,
10
+ cardTokenization,
11
+ googlePayConfig,
12
+ } from "./method-capability";
13
+ import type { ProviderCheckoutScreenProps } from "./providers/types";
14
+ import { useCheckoutComponents } from "./ui";
15
+ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
16
+
17
+ /**
18
+ * The CARD pane with its wallet fast lane (FUT-471/472).
19
+ *
20
+ * A wallet is not a fourth method — it is another way of producing the CARD
21
+ * instrument — so it renders INSIDE the card pane, above the form, and only
22
+ * when the chain head declared the wallet capability (both gates fail closed:
23
+ * `googlePayConfig` / `applePayDeclared` + the device's own support). A store
24
+ * with no wallet renders exactly the card view it always did.
25
+ *
26
+ * One pane owns BOTH submit paths' visibility so they cannot invite a double
27
+ * payment: while a wallet charge is in flight or being confirmed, the card
28
+ * form and every wallet button are REPLACED by the processing state — the same
29
+ * rule `card-view.tsx` applies to its own pay bar, one level up.
30
+ */
31
+
32
+ /** The screen props narrowed to a raised order — what this pane requires. */
33
+ type WalletPaneProps = ProviderCheckoutScreenProps & {
34
+ order: NonNullable<ProviderCheckoutScreenProps["order"]>;
35
+ };
36
+
37
+ /** Post-submit confirmation, error > timeout > spinner — the card view's order. */
38
+ function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
39
+ const { Alert, LoadingState } = useCheckoutComponents();
40
+ if (wallet.pollError) {
41
+ return (
42
+ <Alert
43
+ variant="danger"
44
+ title="Não foi possível confirmar o pagamento"
45
+ description={wallet.pollError}
46
+ showIcon
47
+ data-testid="wallet-poll-error"
48
+ />
49
+ );
50
+ }
51
+ if (wallet.pollTimedOut) {
52
+ return (
53
+ <Alert
54
+ variant="warning"
55
+ title="O pagamento está demorando mais que o esperado"
56
+ description="Você pode aguardar ou verificar seu pedido em instantes — não realize um novo pagamento."
57
+ showIcon
58
+ data-testid="wallet-poll-timeout"
59
+ />
60
+ );
61
+ }
62
+ return (
63
+ <LoadingState
64
+ variant="spinner"
65
+ size="md"
66
+ message="Processando pagamento…"
67
+ dataTestId="wallet-processing"
68
+ />
69
+ );
70
+ }
71
+
72
+ /**
73
+ * An UNRESOLVED wallet charge (FUT-563): some provider may be holding the
74
+ * buyer's money, so the pane shows the warning and NO pay control of any kind
75
+ * — no wallet button, no card form. Same presentation rule as the card view's
76
+ * own unresolved state.
77
+ */
78
+ function WalletUnresolved({ message }: { message: string }): JSX.Element {
79
+ const { Alert } = useCheckoutComponents();
80
+ return (
81
+ <Alert
82
+ variant="warning"
83
+ title="Estamos confirmando seu pagamento"
84
+ description={message}
85
+ showIcon
86
+ data-testid="wallet-unresolved"
87
+ />
88
+ );
89
+ }
90
+
91
+ /**
92
+ * The wallet buttons the store's chain head supports, or null when there are
93
+ * none to offer. The divider renders only once SOMETHING sits above it: Apple
94
+ * availability is known synchronously (feature-detect), Google's arrives when
95
+ * `isReadyToPay` approves (`onReady`) — a bare "ou pague com cartão" with
96
+ * nothing above it would caption an empty space.
97
+ */
98
+ function WalletButtons({
99
+ props,
100
+ wallet,
101
+ onSheetError,
102
+ }: {
103
+ props: WalletPaneProps;
104
+ wallet: WalletCharge;
105
+ onSheetError: (message: string) => void;
106
+ }): JSX.Element | null {
107
+ const { Text } = useCheckoutComponents();
108
+ const [googleReady, setGoogleReady] = useState(false);
109
+ const googlePay = googlePayConfig(props.config);
110
+ const applePay = applePayDeclared(props.config) && applePaySupported();
111
+ if (!googlePay && !applePay) return null;
112
+ return (
113
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
114
+ {applePay ? (
115
+ <ApplePayButton
116
+ order={props.order}
117
+ onAuthorized={(key) => wallet.payWithKey("APPLE_PAY", key)}
118
+ onError={onSheetError}
119
+ validateMerchant={props.validateApplePayMerchant}
120
+ />
121
+ ) : null}
122
+ {googlePay ? (
123
+ <GooglePayButton
124
+ order={props.order}
125
+ params={googlePay}
126
+ onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
127
+ onError={onSheetError}
128
+ onReady={() => setGoogleReady(true)}
129
+ />
130
+ ) : null}
131
+ {applePay || googleReady ? (
132
+ <Divider>
133
+ <Text variant="caption" size="xs" color="secondary" as="span">
134
+ ou pague com cartão
135
+ </Text>
136
+ </Divider>
137
+ ) : null}
138
+ </Box>
139
+ );
140
+ }
141
+
142
+ /** The CARD pane: wallet fast lane above, the card form below. */
143
+ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
144
+ const { Alert } = useCheckoutComponents();
145
+ const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
146
+ const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs);
147
+ // A sheet failure the wallet reported before any charge existed (pay.js
148
+ // refused, merchant validation unavailable, the sheet errored) — shown
149
+ // beside the form, which stays usable.
150
+ const [sheetError, setSheetError] = useState<string | null>(null);
151
+
152
+ if (wallet.phase !== "idle") return <WalletProcessing wallet={wallet} />;
153
+ if (wallet.unresolved) return <WalletUnresolved message={wallet.error ?? ""} />;
154
+
155
+ return (
156
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
157
+ <WalletButtons props={props} wallet={wallet} onSheetError={setSheetError} />
158
+ {wallet.error ? (
159
+ <Alert
160
+ variant="danger"
161
+ title="Não foi possível pagar"
162
+ description={wallet.error}
163
+ showIcon
164
+ data-testid="wallet-error"
165
+ />
166
+ ) : null}
167
+ {sheetError && !wallet.error ? (
168
+ <Alert
169
+ variant="danger"
170
+ title="Não foi possível pagar"
171
+ description={sheetError}
172
+ showIcon
173
+ data-testid="wallet-sheet-error"
174
+ />
175
+ ) : null}
176
+ <CardView
177
+ order={order}
178
+ buyer={buyer}
179
+ providerConfig={cardTokenization(config)}
180
+ providerChain={cardChain(config)}
181
+ tenantSlug={tenantSlug}
182
+ onResolved={onResolved}
183
+ pollIntervalMs={pollIntervalMs}
184
+ />
185
+ </Box>
186
+ );
187
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, Stack, Typography } from '@mui/material';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import type { ConnectApplicationReport, PaymentEnvironment } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX, ConnectEnvironmentCard } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The platform's PagBank Connect application, per environment (FUT-479,
12
+ * packaged by FUT-573).
13
+ *
14
+ * The application every store authorizes against is registered by hand, so
15
+ * nothing in the product could say what is registered, in which environment,
16
+ * or with which redirect URI. This panel is the consult
17
+ * (`GET /oauth2/application/{client_id}`) made permanent: per environment —
18
+ * sandbox and produção are separate applications — it shows what PagBank has
19
+ * on file, including the exact redirect_uri, and flags a mismatch against the
20
+ * callback the deployment actually uses (a mismatch is a silent OAuth
21
+ * failure). Read-only: creating an application stays a deliberate manual act.
22
+ *
23
+ * Dumb by design: the HOST fetches the report from its own mounted route
24
+ * (`consultConnectApplications` in `@12-apps/payments-backend`) and passes it
25
+ * here, so the host page is a thin mount — page chrome, auth and loading
26
+ * belong to the host; the screen itself lives in this package.
27
+ */
28
+ export interface ConnectApplicationPanelProps {
29
+ /** The consult report, as the backend's `consultConnectApplications` answers. */
30
+ report: ConnectApplicationReport;
31
+ /** Re-run the consult. Omitted, the refresh button is not rendered. */
32
+ onRefresh?: () => void;
33
+ /**
34
+ * Which host-side variables feed one environment's application — the host's
35
+ * own configuration surface, rendered as a collapsible per-environment help
36
+ * when provided.
37
+ */
38
+ configVarsFor?: (environment: PaymentEnvironment) => string[];
39
+ }
40
+
41
+ export function ConnectApplicationPanel(props: ConnectApplicationPanelProps): ReactNode {
42
+ const { report, onRefresh, configVarsFor } = props;
43
+ return (
44
+ <Stack spacing={2} data-testid="connect-application-panel">
45
+ <Stack spacing={0.5} data-testid="connect-expected-redirect" sx={CARD_SX}>
46
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
47
+ Callback desta instalação (o valor que precisa estar registrado)
48
+ </Typography>
49
+ <Box
50
+ component="code"
51
+ sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
52
+ >
53
+ {report.expectedRedirectUri}
54
+ </Box>
55
+ </Stack>
56
+ {report.environments.map((status) => (
57
+ <ConnectEnvironmentCard
58
+ key={status.environment}
59
+ status={status}
60
+ configVars={configVarsFor?.(status.environment)}
61
+ />
62
+ ))}
63
+ {onRefresh ? (
64
+ <Box>
65
+ <Button
66
+ variant="outlined"
67
+ size="small"
68
+ onClick={() => onRefresh()}
69
+ data-testid="connect-refresh"
70
+ >
71
+ Consultar novamente
72
+ </Button>
73
+ </Box>
74
+ ) : null}
75
+ </Stack>
76
+ );
77
+ }