@12-apps/payments-frontend 1.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.17.0",
3
+ "version": "1.18.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.17.0",
20
+ "@12-apps/payments-backend": "^1.18.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.19.0",
32
- "@12-apps/typescript-config": "^1.19.0",
31
+ "@12-apps/eslint-config": "^1.20.0",
32
+ "@12-apps/typescript-config": "^1.20.0",
33
33
  "@emotion/react": "^11.14.0",
34
34
  "@emotion/styled": "^11.14.0",
35
35
  "@mui/material": "^6.5.0",
@@ -16,6 +16,7 @@ import {
16
16
  import { useState } from 'react';
17
17
 
18
18
  import type {
19
+ ConnectedOAuthAccount,
19
20
  MaskedProviderConfig,
20
21
  PaymentEnvironment,
21
22
  ProviderDescriptor,
@@ -85,6 +86,54 @@ function connectLabel(displayName: string, connected: boolean, busy: string | nu
85
86
  return connected ? 'Reconectar' : `Conectar com ${displayName}`;
86
87
  }
87
88
 
89
+ /**
90
+ * pt-BR labels for the scopes a store owner actually sees. Unknown scopes fall
91
+ * back to the provider's own spelling — wrong words are worse than raw ones.
92
+ */
93
+ const SCOPE_LABELS: Record<string, string> = {
94
+ 'payments.read': 'Consultar pagamentos',
95
+ 'payments.create': 'Criar cobranças',
96
+ 'payments.refund': 'Estornar pagamentos',
97
+ 'accounts.read': 'Consultar dados da conta',
98
+ };
99
+
100
+ /**
101
+ * WHICH account is connected (FUT-300): identity, granted scopes and the
102
+ * connect date — the difference between "conectado" and "conectado como".
103
+ * Without it, the only way an owner could tell which PagBank account a store
104
+ * charges into was to disconnect and connect again.
105
+ */
106
+ function ConnectedAccountDetails(props: { account: ConnectedOAuthAccount }) {
107
+ const { account } = props;
108
+ const identity = account.accountLabel ?? account.accountId;
109
+ return (
110
+ <Stack spacing={0.5} data-testid="payments-connected-account">
111
+ {identity ? (
112
+ <Typography variant="body2">
113
+ Conta conectada: <strong>{identity}</strong>
114
+ </Typography>
115
+ ) : null}
116
+ {account.connectedAt ? (
117
+ <Typography variant="caption" color="text.secondary">
118
+ {`Conectada em ${new Date(account.connectedAt).toLocaleString('pt-BR')}`}
119
+ </Typography>
120
+ ) : null}
121
+ {account.grantedScopes.length > 0 ? (
122
+ <Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
123
+ {account.grantedScopes.map((scope) => (
124
+ <Chip
125
+ key={scope}
126
+ size="small"
127
+ variant="outlined"
128
+ label={SCOPE_LABELS[scope] ?? scope}
129
+ />
130
+ ))}
131
+ </Stack>
132
+ ) : null}
133
+ </Stack>
134
+ );
135
+ }
136
+
88
137
  /** Header + explanatory copy + any warning banner for the connection. */
89
138
  function ConnectionSummary(props: {
90
139
  displayName: string;
@@ -92,6 +141,7 @@ function ConnectionSummary(props: {
92
141
  connected: boolean;
93
142
  expiresAt: string | null;
94
143
  environment: PaymentEnvironment;
144
+ connectedAccount: ConnectedOAuthAccount | null;
95
145
  }) {
96
146
  return (
97
147
  <>
@@ -118,6 +168,9 @@ function ConnectionSummary(props: {
118
168
  ? 'Sua conta está conectada. O Future Pay cria as cobranças em seu nome — nenhuma chave precisa ser copiada.'
119
169
  : `Conecte sua conta ${props.displayName} autorizando o acesso no site do provedor. Nenhuma chave precisa ser copiada.`}
120
170
  </Typography>
171
+ {props.connected && props.connectedAccount ? (
172
+ <ConnectedAccountDetails account={props.connectedAccount} />
173
+ ) : null}
121
174
  {props.status === 'RECONNECT_REQUIRED' ? (
122
175
  <Alert severity="warning">
123
176
  A autorização expirou ou foi revogada. Reconecte para voltar a receber pagamentos.
@@ -243,6 +296,7 @@ export function ProviderConnection(props: ProviderConnectionProps) {
243
296
  connected={connected}
244
297
  expiresAt={config?.expiresAt ?? null}
245
298
  environment={environment}
299
+ connectedAccount={config?.connectedAccount ?? null}
246
300
  />
247
301
 
248
302
  {error ? <Alert severity="error">{error}</Alert> : null}
@@ -0,0 +1,189 @@
1
+ import { Box } from "@mui/material";
2
+ import { useRef, type JSX } from "react";
3
+
4
+ import type { CheckoutOrder } from "./types";
5
+
6
+ /**
7
+ * The Apple-owned pay button (FUT-472), on `ApplePaySession` — the API Safari
8
+ * ships natively; there is no script to load. This component owns token
9
+ * ACQUISITION: feature-detect, render Apple's button, run the session, and
10
+ * turn an authorized payment into the wallet key (Apple's `token.paymentData`,
11
+ * serialized verbatim). The charge itself belongs to the pane above
12
+ * (`wallet-pane.tsx`), which reports back so the sheet can be completed with
13
+ * the honest status.
14
+ *
15
+ * ## Visa and Mastercard ONLY — a money rule, not a default
16
+ *
17
+ * PagBank currently processes Apple Pay for Visa and Mastercard alone. The
18
+ * sheet's `supportedNetworks` is the one gate that keeps every other card
19
+ * from being OFFERED: a shopper whose wallet holds only an Elo card sees the
20
+ * sheet refuse selection instead of authorizing a payment PagBank then
21
+ * declines. Widening this list is a provider fact change, not a UI choice.
22
+ *
23
+ * ## The fallback is the card form
24
+ *
25
+ * A device without Apple Pay, a store that never declared the wallet, or a
26
+ * merchant validation that cannot run all degrade the same way: no button,
27
+ * and the card form the pane always renders stays. The buyer loses a
28
+ * shortcut, never the ability to pay.
29
+ */
30
+
31
+ /** PagBank processes Apple Pay for these networks ONLY. See module comment. */
32
+ export const APPLE_PAY_SUPPORTED_NETWORKS = ["visa", "masterCard"] as const;
33
+
34
+ /** ApplePaySession API version 3 — the floor for the fields this sheet uses. */
35
+ const APPLE_PAY_VERSION = 3;
36
+
37
+ /** The payment-request slice this checkout builds. */
38
+ export interface ApplePayPaymentRequest {
39
+ countryCode: string;
40
+ currencyCode: string;
41
+ supportedNetworks: readonly string[];
42
+ merchantCapabilities: readonly string[];
43
+ total: { label: string; amount: string };
44
+ }
45
+
46
+ /** The session events/methods this button drives. */
47
+ export interface ApplePaySessionLike {
48
+ onvalidatemerchant: ((event: { validationURL: string }) => void) | null;
49
+ onpaymentauthorized: ((event: { payment: { token: { paymentData: unknown } } }) => void) | null;
50
+ oncancel: (() => void) | null;
51
+ begin(): void;
52
+ abort(): void;
53
+ completeMerchantValidation(merchantSession: unknown): void;
54
+ completePayment(result: { status: number }): void;
55
+ }
56
+
57
+ /** The `window.ApplePaySession` constructor, as far as this button needs it. */
58
+ export interface ApplePaySessionClass {
59
+ new (version: number, request: ApplePayPaymentRequest): ApplePaySessionLike;
60
+ canMakePayments(): boolean;
61
+ STATUS_SUCCESS: number;
62
+ STATUS_FAILURE: number;
63
+ }
64
+
65
+ /** The native constructor, where Safari (or a harness) provides one. */
66
+ function applePaySessionClass(): ApplePaySessionClass | null {
67
+ if (typeof window === "undefined") return null;
68
+ const scope = window as unknown as { ApplePaySession?: ApplePaySessionClass };
69
+ return scope.ApplePaySession ?? null;
70
+ }
71
+
72
+ /**
73
+ * Whether THIS device can show the sheet at all — the synchronous half of the
74
+ * gate (the capability half is `applePayDeclared`). Exported so the pane can
75
+ * decide whether any wallet chrome (the divider) is worth rendering.
76
+ */
77
+ export function applePaySupported(): boolean {
78
+ const Session = applePaySessionClass();
79
+ if (!Session) return false;
80
+ try {
81
+ return Session.canMakePayments();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** The sheet's payment request, from the server-authoritative order total. */
88
+ function paymentRequest(order: CheckoutOrder): ApplePayPaymentRequest {
89
+ return {
90
+ countryCode: "BR",
91
+ currencyCode: "BRL",
92
+ supportedNetworks: APPLE_PAY_SUPPORTED_NETWORKS,
93
+ merchantCapabilities: ["supports3DS"],
94
+ total: { label: "Total do pedido", amount: (order.totalCents / 100).toFixed(2) },
95
+ };
96
+ }
97
+
98
+ export interface ApplePayButtonProps {
99
+ order: CheckoutOrder;
100
+ /**
101
+ * The payment authorized on the sheet — charge this key. Resolves `true`
102
+ * when the charge was accepted (paid, or confirming), `false` on a refusal,
103
+ * so the sheet can be completed with the honest status.
104
+ */
105
+ onAuthorized: (key: string) => Promise<boolean>;
106
+ /** A session failure worth telling the buyer about (not a dismissal). */
107
+ onError: (message: string) => void;
108
+ /**
109
+ * The host's merchant-validation port: exchange `validationURL` for an
110
+ * Apple merchant session, SERVER-SIDE (the merchant identity certificate
111
+ * must never reach a browser). Absent — the external prerequisites are not
112
+ * done, or the host has not wired it — the session aborts with a pt-BR
113
+ * message and the card form remains the way to pay.
114
+ */
115
+ validateMerchant?: (validationURL: string) => Promise<unknown>;
116
+ }
117
+
118
+ /** Wire one session run. Split from the component for the size gate. */
119
+ function runSession(
120
+ Session: ApplePaySessionClass,
121
+ order: CheckoutOrder,
122
+ handlers: ApplePayButtonProps,
123
+ ): void {
124
+ const session = new Session(APPLE_PAY_VERSION, paymentRequest(order));
125
+ session.onvalidatemerchant = (event) => {
126
+ const validate = handlers.validateMerchant;
127
+ if (!validate) {
128
+ session.abort();
129
+ handlers.onError("Não foi possível iniciar o Apple Pay nesta loja. Pague com cartão.");
130
+ return;
131
+ }
132
+ validate(event.validationURL)
133
+ .then((merchantSession) => session.completeMerchantValidation(merchantSession))
134
+ .catch(() => {
135
+ session.abort();
136
+ handlers.onError("Não foi possível iniciar o Apple Pay. Tente novamente ou pague com cartão.");
137
+ });
138
+ };
139
+ session.onpaymentauthorized = (event) => {
140
+ // Apple's `token.paymentData`, serialized VERBATIM — PagBank's `key`.
141
+ const key = JSON.stringify(event.payment.token.paymentData);
142
+ void handlers.onAuthorized(key).then((accepted) => {
143
+ session.completePayment({
144
+ status: accepted ? Session.STATUS_SUCCESS : Session.STATUS_FAILURE,
145
+ });
146
+ });
147
+ };
148
+ // Closing the sheet is a choice, not a failure to report.
149
+ session.oncancel = () => undefined;
150
+ session.begin();
151
+ }
152
+
153
+ /**
154
+ * Renders NOTHING unless this device can pay — the sheet's own
155
+ * `supportedNetworks` then keeps unsupported cards from being offered on a
156
+ * device that can. The pixels are Apple's (`-apple-pay-button` appearance, as
157
+ * their Human Interface Guidelines require); only Safari ever renders this,
158
+ * because only Safari passes the feature-detect.
159
+ */
160
+ export function ApplePayButton(props: ApplePayButtonProps): JSX.Element | null {
161
+ // The latest handlers/order, so a session opened from a click never closes
162
+ // over a stale charge target.
163
+ const current = useRef(props);
164
+ current.current = props;
165
+ const Session = applePaySessionClass();
166
+ if (!Session || !applePaySupported()) return null;
167
+ return (
168
+ <Box
169
+ component="button"
170
+ type="button"
171
+ aria-label="Pagar com Apple Pay"
172
+ data-testid="apple-pay-button"
173
+ onClick={() => runSession(Session, current.current.order, current.current)}
174
+ sx={{
175
+ WebkitAppearance: "-apple-pay-button",
176
+ // Apple draws the button; these only give it room. The fallback
177
+ // colors are unreachable in practice (only Safari gets here) but keep
178
+ // the element visible if the appearance ever fails to apply.
179
+ height: 40,
180
+ width: "100%",
181
+ border: 0,
182
+ borderRadius: 2,
183
+ cursor: "pointer",
184
+ bgcolor: "common.black",
185
+ color: "common.white",
186
+ }}
187
+ />
188
+ );
189
+ }
@@ -46,6 +46,13 @@ export interface CheckoutFlowProps extends CheckoutHostPorts {
46
46
  providerConfig?: CheckoutProviderConfig | null;
47
47
  /** Scopes the saved-card list to the store being paid. */
48
48
  tenantSlug?: string;
49
+ /**
50
+ * The host's Apple Pay merchant-validation port (FUT-472): exchange the
51
+ * session's `validationURL` for an Apple merchant session, SERVER-SIDE.
52
+ * Optional — without it the Apple Pay sheet cannot start, and the card form
53
+ * remains the way to pay.
54
+ */
55
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
49
56
  /** Host content shown on the paid confirmation (the storefront's install invite). */
50
57
  confirmationExtra?: ReactNode;
51
58
  /** Design-system slots; unfilled slots render the raw-MUI defaults. */
@@ -118,7 +125,7 @@ function ProgressHeader({ step, completed }: { step: string; completed: Set<stri
118
125
  * card public key are loaded lazily by the card path (order-scoped REST).
119
126
  */
120
127
  function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Element {
121
- const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, ...ports } = props;
128
+ const { cart, defaultBuyer, comanda, taxIdOnFile = false, providerConfig, tenantSlug, confirmationExtra, validateApplePayMerchant, ...ports } = props;
122
129
  // Resolved for NO method on purpose (FUT-595): the Dados step opens before
123
130
  // the picker, and the form is filled once — so it asks for the union of what
124
131
  // any chain member may need rather than re-opening after the choice. A chain
@@ -176,6 +183,7 @@ function CheckoutFlowBody(props: Omit<CheckoutFlowProps, "components">): JSX.Ele
176
183
  onEditBuyer={c.editBuyer}
177
184
  providerConfig={providerConfig}
178
185
  tenantSlug={tenantSlug}
186
+ validateApplePayMerchant={validateApplePayMerchant}
179
187
  onResolved={c.handleResolved}
180
188
  />
181
189
  ) : null}
@@ -71,6 +71,7 @@ function PaymentBody({
71
71
  tenantSlug,
72
72
  onResolved,
73
73
  pollIntervalMs,
74
+ validateApplePayMerchant,
74
75
  }: {
75
76
  order: CheckoutOrder | null;
76
77
  buyer: BuyerInfo;
@@ -79,6 +80,7 @@ function PaymentBody({
79
80
  tenantSlug?: string;
80
81
  onResolved: (status: OrderStatus) => void;
81
82
  pollIntervalMs?: number;
83
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
82
84
  }): JSX.Element | null {
83
85
  const Screen = resolveCheckoutScreen(providerConfig?.chain?.[0]?.checkoutScreen);
84
86
  return (
@@ -90,6 +92,7 @@ function PaymentBody({
90
92
  tenantSlug={tenantSlug}
91
93
  onResolved={onResolved}
92
94
  pollIntervalMs={pollIntervalMs}
95
+ validateApplePayMerchant={validateApplePayMerchant}
93
96
  />
94
97
  );
95
98
  }
@@ -291,6 +294,8 @@ interface PaymentStepProps {
291
294
  /** Scopes the saved-card list to the store being paid (host routing owns it). */
292
295
  tenantSlug?: string;
293
296
  pollIntervalMs?: number;
297
+ /** The host's Apple Pay merchant-validation port (FUT-472) — see the screen contract. */
298
+ validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
294
299
  onResolved: (status: OrderStatus) => void;
295
300
  }
296
301
 
@@ -314,6 +319,7 @@ export function PaymentStep({
314
319
  providerConfig,
315
320
  tenantSlug,
316
321
  pollIntervalMs,
322
+ validateApplePayMerchant,
317
323
  onResolved,
318
324
  }: PaymentStepProps): JSX.Element {
319
325
  const { LoadingState } = useCheckoutComponents();
@@ -342,6 +348,7 @@ export function PaymentStep({
342
348
  tenantSlug={tenantSlug}
343
349
  onResolved={onResolved}
344
350
  pollIntervalMs={pollIntervalMs}
351
+ validateApplePayMerchant={validateApplePayMerchant}
345
352
  />
346
353
 
347
354
  {!order && creating ? (
@@ -196,6 +196,11 @@ export interface GooglePayButtonProps {
196
196
  environment?: "TEST" | "PRODUCTION";
197
197
  /** Injectable API namespace for tests/harnesses; omit to load pay.js. */
198
198
  api?: GooglePayApi | null;
199
+ /**
200
+ * Fired once `isReadyToPay` approved and the button will render — how the
201
+ * pane knows its wallet chrome (the divider) has something to sit above.
202
+ */
203
+ onReady?: () => void;
199
204
  }
200
205
 
201
206
  /**
@@ -211,13 +216,18 @@ export function GooglePayButton({
211
216
  onError,
212
217
  environment = "TEST",
213
218
  api,
219
+ onReady,
214
220
  }: GooglePayButtonProps): JSX.Element | null {
215
221
  const client = useGooglePayClient(api, environment);
216
222
  const container = useRef<HTMLDivElement | null>(null);
217
223
  // The latest handlers/order, so the Google-rendered button — mounted once —
218
224
  // never closes over a stale charge target.
219
- const current = useRef({ order, params, onKey, onError });
220
- current.current = { order, params, onKey, onError };
225
+ const current = useRef({ order, params, onKey, onError, onReady });
226
+ current.current = { order, params, onKey, onError, onReady };
227
+
228
+ useEffect(() => {
229
+ if (client) current.current.onReady?.();
230
+ }, [client]);
221
231
 
222
232
  useEffect(() => {
223
233
  const mount = container.current;
@@ -167,6 +167,24 @@ export function googlePayConfig(
167
167
  return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
168
168
  }
169
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
+
170
188
  /**
171
189
  * The methods the picker may offer, from the chain's declared capabilities
172
190
  * (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
@@ -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
  }
@@ -39,8 +39,13 @@ export interface WalletCharge {
39
39
  pollError: string | null;
40
40
  /** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
41
41
  pollTimedOut: boolean;
42
- /** Charge the wallet's key. The button calls this once the sheet resolves. */
43
- payWithKey(type: CheckoutWalletType, key: string): Promise<void>;
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>;
44
49
  }
45
50
 
46
51
  /**
@@ -72,7 +77,7 @@ export function useWalletCharge(
72
77
  if (status && status !== "AWAITING_PAYMENT") onResolved(status);
73
78
  }, [status, onResolved]);
74
79
 
75
- const payWithKey = async (type: CheckoutWalletType, key: string): Promise<void> => {
80
+ const payWithKey = async (type: CheckoutWalletType, key: string): Promise<boolean> => {
76
81
  setError(null);
77
82
  setErrorCode(null);
78
83
  setPhase("charging");
@@ -92,7 +97,7 @@ export function useWalletCharge(
92
97
  // holding the money, and a live button under "não pague de novo" is what
93
98
  // the buyer's thumb reaches for.
94
99
  setPhase("idle");
95
- return;
100
+ return false;
96
101
  }
97
102
  // A provider that demands its own page to finish (redirect 3-D Secure,
98
103
  // FUT-698): park the order and hand the buyer over, exactly as the card
@@ -100,15 +105,16 @@ export function useWalletCharge(
100
105
  if (charged.data.hostedCheckoutUrl) {
101
106
  rememberHostedOrder(order);
102
107
  navigate(charged.data.hostedCheckoutUrl);
103
- return;
108
+ return true;
104
109
  }
105
110
  // A business outcome (declined → FAILED) shows the status screen; an
106
111
  // accepted charge begins polling for the async confirmation.
107
112
  if (charged.data.status !== "AWAITING_PAYMENT") {
108
113
  onResolved(charged.data.status);
109
- return;
114
+ return charged.data.status === "PAID";
110
115
  }
111
116
  setPhase("polling");
117
+ return true;
112
118
  };
113
119
 
114
120
  return {
@@ -1,9 +1,15 @@
1
1
  import { Box, Divider } from "@mui/material";
2
2
  import { useState, type JSX } from "react";
3
3
 
4
+ import { ApplePayButton, applePaySupported } from "./apple-pay-button";
4
5
  import { CardView } from "./card-view";
5
6
  import { GooglePayButton } from "./google-pay-button";
6
- import { cardChain, cardTokenization, googlePayConfig } from "./method-capability";
7
+ import {
8
+ applePayDeclared,
9
+ cardChain,
10
+ cardTokenization,
11
+ googlePayConfig,
12
+ } from "./method-capability";
7
13
  import type { ProviderCheckoutScreenProps } from "./providers/types";
8
14
  import { useCheckoutComponents } from "./ui";
9
15
  import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
@@ -13,9 +19,9 @@ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
13
19
  *
14
20
  * A wallet is not a fourth method — it is another way of producing the CARD
15
21
  * instrument — so it renders INSIDE the card pane, above the form, and only
16
- * when the chain head both declared the wallet capability and published the
17
- * parameters the browser needs (`googlePayConfig`, fail-closed). A store with
18
- * no wallet renders exactly the card view it always did.
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.
19
25
  *
20
26
  * One pane owns BOTH submit paths' visibility so they cannot invite a double
21
27
  * payment: while a wallet charge is in flight or being confirmed, the card
@@ -23,6 +29,11 @@ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
23
29
  * rule `card-view.tsx` applies to its own pay bar, one level up.
24
30
  */
25
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
+
26
37
  /** Post-submit confirmation, error > timeout > spinner — the card view's order. */
27
38
  function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
28
39
  const { Alert, LoadingState } = useCheckoutComponents();
@@ -79,48 +90,63 @@ function WalletUnresolved({ message }: { message: string }): JSX.Element {
79
90
 
80
91
  /**
81
92
  * The wallet buttons the store's chain head supports, or null when there are
82
- * none. Split out so the pane below stays under the function-size gate.
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.
83
97
  */
84
98
  function WalletButtons({
85
99
  props,
86
100
  wallet,
87
101
  onSheetError,
88
102
  }: {
89
- props: ProviderCheckoutScreenProps & { order: NonNullable<ProviderCheckoutScreenProps["order"]> };
103
+ props: WalletPaneProps;
90
104
  wallet: WalletCharge;
91
105
  onSheetError: (message: string) => void;
92
106
  }): JSX.Element | null {
93
107
  const { Text } = useCheckoutComponents();
108
+ const [googleReady, setGoogleReady] = useState(false);
94
109
  const googlePay = googlePayConfig(props.config);
95
- if (!googlePay) return null;
110
+ const applePay = applePayDeclared(props.config) && applePaySupported();
111
+ if (!googlePay && !applePay) return null;
96
112
  return (
97
113
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
98
- <GooglePayButton
99
- order={props.order}
100
- params={googlePay}
101
- onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
102
- onError={onSheetError}
103
- />
104
- <Divider>
105
- <Text variant="caption" size="xs" color="secondary" as="span">
106
- ou pague com cartão
107
- </Text>
108
- </Divider>
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}
109
138
  </Box>
110
139
  );
111
140
  }
112
141
 
113
142
  /** The CARD pane: wallet fast lane above, the card form below. */
114
- export function WalletCardPane(
115
- props: ProviderCheckoutScreenProps & {
116
- order: NonNullable<ProviderCheckoutScreenProps["order"]>;
117
- },
118
- ): JSX.Element {
143
+ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
119
144
  const { Alert } = useCheckoutComponents();
120
145
  const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
121
146
  const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs);
122
147
  // A sheet failure the wallet reported before any charge existed (pay.js
123
- // refused, the sheet errored) — shown beside the form, which stays usable.
148
+ // refused, merchant validation unavailable, the sheet errored) — shown
149
+ // beside the form, which stays usable.
124
150
  const [sheetError, setSheetError] = useState<string | null>(null);
125
151
 
126
152
  if (wallet.phase !== "idle") return <WalletProcessing wallet={wallet} />;
@@ -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
+ }
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Stack, Typography } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import type { ConnectApplicationStatus, PaymentEnvironment } from '@12-apps/payments-backend';
7
+
8
+ /**
9
+ * One environment's Connect application (FUT-479, packaged by FUT-573).
10
+ * Sandbox and produção are SEPARATE applications with separate id/secret
11
+ * pairs, so each card stands on its own: configured or not, what PagBank says
12
+ * is registered, and whether the registered redirect URI matches the
13
+ * deployment's callback.
14
+ */
15
+
16
+ const ENV_LABEL: Record<PaymentEnvironment, string> = {
17
+ SANDBOX: 'Sandbox',
18
+ PRODUCTION: 'Produção',
19
+ };
20
+
21
+ /** The bordered-card look every block of these screens shares. */
22
+ export const CARD_SX = {
23
+ border: 1,
24
+ borderColor: 'divider',
25
+ borderRadius: 2,
26
+ p: 2,
27
+ } as const;
28
+
29
+ function Field({ label, children }: { label: string; children: ReactNode }): ReactNode {
30
+ return (
31
+ <Box
32
+ sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 180, wordBreak: 'break-all' }}
33
+ >
34
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
35
+ {label}
36
+ </Typography>
37
+ <Typography component="span" variant="body2">
38
+ {children}
39
+ </Typography>
40
+ </Box>
41
+ );
42
+ }
43
+
44
+ /** The verdict the screen exists for: does the registered callback match ours? */
45
+ function MismatchAlert({ status }: { status: ConnectApplicationStatus }): ReactNode {
46
+ if (status.application === null) return null;
47
+ if (status.redirectUriMismatch === true) {
48
+ return (
49
+ <Alert severity="error" data-testid={`connect-mismatch-${status.environment}`}>
50
+ A redirect_uri registrada no PagBank é diferente do callback desta instalação. O fluxo
51
+ de autorização OAuth falha silenciosamente até o cadastro ser corrigido no PagBank.
52
+ </Alert>
53
+ );
54
+ }
55
+ if (status.redirectUriMismatch === false) {
56
+ return (
57
+ <Alert severity="success" data-testid={`connect-match-${status.environment}`}>
58
+ A redirect_uri registrada confere com o callback desta instalação.
59
+ </Alert>
60
+ );
61
+ }
62
+ return (
63
+ <Alert severity="warning" data-testid={`connect-unknown-${status.environment}`}>
64
+ A resposta do PagBank não informou a redirect_uri — não foi possível comparar com o
65
+ callback desta instalação.
66
+ </Alert>
67
+ );
68
+ }
69
+
70
+ /** What PagBank reports as registered, plus whatever extra keys came back. */
71
+ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): ReactNode {
72
+ const app = status.application;
73
+ if (app === null) return null;
74
+ const extraKeys = Object.keys(app.extra);
75
+ return (
76
+ <Stack spacing={1.5}>
77
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5 }}>
78
+ <Field label="Nome (exibido ao lojista)">{app.name ?? '—'}</Field>
79
+ <Field label="Site">{app.site ?? '—'}</Field>
80
+ <Field label="Descrição">{app.description ?? '—'}</Field>
81
+ <Field label="Logo">{app.logo ?? '—'}</Field>
82
+ <Field label="redirect_uri registrada">{app.redirectUri ?? 'não informada'}</Field>
83
+ </Box>
84
+ {extraKeys.length > 0 ? (
85
+ <Box data-testid={`connect-extra-${status.environment}`}>
86
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
87
+ Outros campos retornados (schema não documentado)
88
+ </Typography>
89
+ <Box component="pre" sx={{ m: 0, fontSize: 12, overflowX: 'auto' }}>
90
+ {JSON.stringify(app.extra, null, 2)}
91
+ </Box>
92
+ </Box>
93
+ ) : null}
94
+ </Stack>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The collapsible "what feeds this environment" help. The variable names are
100
+ * the HOST's own configuration surface, so they arrive via `configVars`; with
101
+ * none provided the toggle is omitted entirely rather than opening on nothing.
102
+ */
103
+ function ConfigHelp({
104
+ environment,
105
+ configVars,
106
+ }: {
107
+ environment: PaymentEnvironment;
108
+ configVars?: string[];
109
+ }): ReactNode {
110
+ const [open, setOpen] = useState(false);
111
+ if (!configVars || configVars.length === 0) return null;
112
+ return (
113
+ <Stack spacing={1} alignItems="flex-start">
114
+ <Button
115
+ variant="outlined"
116
+ size="small"
117
+ onClick={() => setOpen((value) => !value)}
118
+ data-testid={`connect-config-toggle-${environment}`}
119
+ >
120
+ {open ? 'Ocultar variáveis de ambiente' : 'Ver variáveis de ambiente'}
121
+ </Button>
122
+ {open ? (
123
+ <Box data-testid={`connect-config-details-${environment}`}>
124
+ <Typography variant="caption" color="text.secondary" component="p">
125
+ A aplicação deste ambiente é resolvida estritamente por estas variáveis (sem
126
+ fallback entre ambientes):
127
+ </Typography>
128
+ <Box component="ul" sx={{ m: 0, pl: 2.5 }}>
129
+ {configVars.map((name) => (
130
+ <Box component="li" key={name}>
131
+ <Box component="code" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
132
+ {name}
133
+ </Box>
134
+ </Box>
135
+ ))}
136
+ </Box>
137
+ </Box>
138
+ ) : null}
139
+ </Stack>
140
+ );
141
+ }
142
+
143
+ export function ConnectEnvironmentCard({
144
+ status,
145
+ configVars,
146
+ }: {
147
+ status: ConnectApplicationStatus;
148
+ configVars?: string[];
149
+ }): ReactNode {
150
+ return (
151
+ <Stack spacing={1.5} data-testid={`connect-env-${status.environment}`} sx={CARD_SX}>
152
+ <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, wordBreak: 'break-all' }}>
153
+ <Typography variant="body2" fontWeight={600}>
154
+ {ENV_LABEL[status.environment]}
155
+ </Typography>
156
+ <Typography component="span" variant="caption" color="text.secondary">
157
+ {status.clientId !== null ? `client_id: ${status.clientId}` : ''}
158
+ </Typography>
159
+ </Box>
160
+ {!status.configured ? (
161
+ <Typography variant="body2" color="text.secondary">
162
+ Nenhuma aplicação configurada neste ambiente.
163
+ </Typography>
164
+ ) : null}
165
+ {status.error !== null ? (
166
+ <Alert severity="warning" data-testid={`connect-error-${status.environment}`}>
167
+ {status.error}
168
+ </Alert>
169
+ ) : null}
170
+ <MismatchAlert status={status} />
171
+ <ApplicationFields status={status} />
172
+ <ConfigHelp environment={status.environment} configVars={configVars} />
173
+ </Stack>
174
+ );
175
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import { Box, Link, Stack, Typography } from '@mui/material';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import type { HomologacaoGuide } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The paste-ready homologação answers (FUT-483, packaged by FUT-573) — the
12
+ * Pipefy form with every deployment-specific value already computed, and the
13
+ * services list naming BOTH Order and Connect.
14
+ */
15
+
16
+ /** One paste-ready value, in a copyable code block. */
17
+ function Answer({ label, children }: { label: string; children: ReactNode }): ReactNode {
18
+ return (
19
+ <Stack spacing={0.25}>
20
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
21
+ {label}
22
+ </Typography>
23
+ <Box
24
+ component="code"
25
+ sx={{
26
+ fontFamily: 'monospace',
27
+ fontSize: 12,
28
+ p: 1,
29
+ borderRadius: 1,
30
+ bgcolor: 'action.hover',
31
+ wordBreak: 'break-word',
32
+ whiteSpace: 'pre-wrap',
33
+ }}
34
+ >
35
+ {children}
36
+ </Box>
37
+ </Stack>
38
+ );
39
+ }
40
+
41
+ export function HomologacaoGuideCard({ guide }: { guide: HomologacaoGuide }): ReactNode {
42
+ return (
43
+ <Stack spacing={1.5} data-testid="homologacao-guide-card" sx={CARD_SX}>
44
+ <Typography variant="body2" fontWeight={600}>
45
+ Formulário de homologação — respostas prontas
46
+ </Typography>
47
+ <Typography variant="body2" color="text.secondary" component="p">
48
+ Abra o{' '}
49
+ <Link
50
+ href={guide.formUrl}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ data-testid="homologacao-form-link"
54
+ >
55
+ formulário oficial de homologação
56
+ </Link>{' '}
57
+ e preencha com os valores abaixo. Em paralelo, abra um chamado no{' '}
58
+ <Link href={guide.supportFormUrl} target="_blank" rel="noreferrer">
59
+ SIP — Suporte Integração PagBank
60
+ </Link>{' '}
61
+ citando o 403 ACCESS_DENIED — o que responder primeiro resolve a dúvida de o
62
+ formulário cobrir ou não o Connect. Documentação:{' '}
63
+ <Link href={guide.docsUrl} target="_blank" rel="noreferrer">
64
+ solicitar homologação
65
+ </Link>
66
+ .
67
+ </Typography>
68
+ <Answer label="Selecione o tipo de integração">{guide.integrationType}</Answer>
69
+ <Box data-testid="homologacao-services">
70
+ <Answer label="Selecione qual serviço você integrou (marque OS DOIS)">
71
+ {guide.services.join('\n')}
72
+ </Answer>
73
+ </Box>
74
+ <Answer label="Instruções de acesso ao seu ambiente (limite de 255 caracteres)">
75
+ {guide.accessInstructions}
76
+ </Answer>
77
+ <Answer label="URL do site">{guide.siteUrl}</Answer>
78
+ <Answer label="Detalhe quais produtos/serviços serão comercializados">
79
+ {guide.productsDescription}
80
+ </Answer>
81
+ <Typography variant="caption" color="text.secondary" component="p">
82
+ {guide.slaText}
83
+ </Typography>
84
+ </Stack>
85
+ );
86
+ }
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Chip, Stack, TextField, Typography } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import type { PlatformHomologationStatus } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The homologação outcome record (FUT-483, packaged by FUT-573) — the durable
12
+ * answer to "is the platform homologated?". Absence of the record is the
13
+ * honest fourth state ("não solicitada"), which is why it is displayed but
14
+ * never offered as a choice.
15
+ */
16
+
17
+ /**
18
+ * One provider's recorded outcome as the wire carries it — the backend's
19
+ * `PlatformHomologationRecord` after JSON serialization (dates as ISO
20
+ * strings).
21
+ */
22
+ export interface PlatformHomologationRecordView {
23
+ provider: string;
24
+ status: PlatformHomologationStatus;
25
+ protocol: string | null;
26
+ notes: string | null;
27
+ submittedAt: string | null;
28
+ decidedAt: string | null;
29
+ updatedBy: string | null;
30
+ updatedAt: string;
31
+ }
32
+
33
+ /** What the outcome form submits — the whole record, replaced deliberately. */
34
+ export interface HomologacaoSaveInput {
35
+ status: PlatformHomologationStatus;
36
+ protocol: string;
37
+ notes: string;
38
+ }
39
+
40
+ /** The host's save-mutation state, whatever machinery produces it. */
41
+ export interface HomologacaoSaveState {
42
+ pending: boolean;
43
+ error: string | null;
44
+ success: boolean;
45
+ }
46
+
47
+ const STATUS_LABEL: Record<PlatformHomologationStatus, string> = {
48
+ SUBMITTED: 'Solicitada',
49
+ APPROVED: 'Aprovada',
50
+ REJECTED: 'Recusada',
51
+ };
52
+
53
+ const STATUS_COLOR: Record<PlatformHomologationStatus, 'warning' | 'success' | 'error'> = {
54
+ SUBMITTED: 'warning',
55
+ APPROVED: 'success',
56
+ REJECTED: 'error',
57
+ };
58
+
59
+ function StatusChip({ record }: { record: PlatformHomologationRecordView | null }): ReactNode {
60
+ if (record === null) {
61
+ return (
62
+ <Chip
63
+ label="Não solicitada"
64
+ size="small"
65
+ variant="outlined"
66
+ data-testid="homologacao-status-chip"
67
+ />
68
+ );
69
+ }
70
+ return (
71
+ <Chip
72
+ label={STATUS_LABEL[record.status]}
73
+ size="small"
74
+ color={STATUS_COLOR[record.status]}
75
+ data-testid="homologacao-status-chip"
76
+ />
77
+ );
78
+ }
79
+
80
+ const formatDateTime = (iso: string): string => new Date(iso).toLocaleString('pt-BR');
81
+
82
+ function RecordTrail({ record }: { record: PlatformHomologationRecordView }): ReactNode {
83
+ return (
84
+ <Typography variant="caption" color="text.secondary" component="p">
85
+ {record.submittedAt ? `Solicitada em ${formatDateTime(record.submittedAt)}. ` : ''}
86
+ {record.decidedAt ? `Decidida em ${formatDateTime(record.decidedAt)}. ` : ''}
87
+ {record.updatedBy ? `Registrado por ${record.updatedBy}.` : ''}
88
+ </Typography>
89
+ );
90
+ }
91
+
92
+ interface HomologacaoOutcomeCardProps {
93
+ record: PlatformHomologationRecordView | null;
94
+ /** Record the outcome — the host PUTs it and refreshes `record`. */
95
+ onSave: (input: HomologacaoSaveInput) => void;
96
+ save: HomologacaoSaveState;
97
+ }
98
+
99
+ export function HomologacaoOutcomeCard(props: HomologacaoOutcomeCardProps): ReactNode {
100
+ const { record, onSave, save } = props;
101
+ const [status, setStatus] = useState<PlatformHomologationStatus>(record?.status ?? 'SUBMITTED');
102
+ const [protocol, setProtocol] = useState(record?.protocol ?? '');
103
+ const [notes, setNotes] = useState(record?.notes ?? '');
104
+
105
+ return (
106
+ <Stack spacing={1.5} data-testid="homologacao-outcome-card" sx={CARD_SX}>
107
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
108
+ <Typography variant="body2" fontWeight={600}>
109
+ Situação da homologação
110
+ </Typography>
111
+ <StatusChip record={record} />
112
+ </Box>
113
+ {record ? <RecordTrail record={record} /> : null}
114
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'flex-start' }}>
115
+ <TextField
116
+ select
117
+ size="small"
118
+ label="Situação"
119
+ value={status}
120
+ onChange={(event) => setStatus(event.target.value as PlatformHomologationStatus)}
121
+ slotProps={{
122
+ select: { native: true },
123
+ htmlInput: { 'data-testid': 'homologacao-status-select' },
124
+ }}
125
+ >
126
+ {(Object.keys(STATUS_LABEL) as PlatformHomologationStatus[]).map((key) => (
127
+ <option key={key} value={key}>
128
+ {STATUS_LABEL[key]}
129
+ </option>
130
+ ))}
131
+ </TextField>
132
+ <TextField
133
+ size="small"
134
+ aria-label="Protocolo"
135
+ placeholder="Protocolo (cartão do Pipefy / chamado)"
136
+ value={protocol}
137
+ onChange={(event) => setProtocol(event.target.value)}
138
+ slotProps={{ htmlInput: { 'data-testid': 'homologacao-protocol' } }}
139
+ />
140
+ <TextField
141
+ size="small"
142
+ aria-label="Observações"
143
+ placeholder="Observações (resposta do PagBank, contexto…)"
144
+ value={notes}
145
+ onChange={(event) => setNotes(event.target.value)}
146
+ slotProps={{ htmlInput: { 'data-testid': 'homologacao-notes' } }}
147
+ />
148
+ <Button
149
+ variant="contained"
150
+ size="small"
151
+ disabled={save.pending}
152
+ onClick={() => onSave({ status, protocol, notes })}
153
+ data-testid="homologacao-save"
154
+ >
155
+ Registrar
156
+ </Button>
157
+ </Box>
158
+ {save.error !== null ? (
159
+ <Alert severity="error" data-testid="homologacao-save-error">
160
+ {save.error}
161
+ </Alert>
162
+ ) : null}
163
+ {save.success ? (
164
+ <Alert severity="success" data-testid="homologacao-save-ok">
165
+ Registro atualizado.
166
+ </Alert>
167
+ ) : null}
168
+ </Stack>
169
+ );
170
+ }
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Stack, Typography } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import type { HomologacaoGuide } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX } from './ConnectEnvironmentCard';
9
+ import { HomologacaoGuideCard } from './HomologacaoGuideCard';
10
+ import {
11
+ HomologacaoOutcomeCard,
12
+ type HomologacaoSaveInput,
13
+ type HomologacaoSaveState,
14
+ type PlatformHomologationRecordView,
15
+ } from './HomologacaoOutcomeCard';
16
+
17
+ /**
18
+ * The PLATFORM's PagBank homologação screen (FUT-483, packaged by FUT-573).
19
+ *
20
+ * The platform is the direct integrator, so the homologação is the
21
+ * platform's, once — store owners are platform users and are exempt. The
22
+ * screen carries the three halves: the recorded outcome (so "is the platform
23
+ * homologated?" stops being a question for a person), the Pipefy form with
24
+ * paste-ready answers (BOTH services — Order and Connect), and the evidence
25
+ * generator running on the platform's own sandbox credentials.
26
+ *
27
+ * Dumb by design: data and mutations arrive via props from the host's own
28
+ * mounted routes (`platformHomologacaoGuide`, `createHomologationRecordService`
29
+ * and `buildPlatformHomologacaoAnexo` in `@12-apps/payments-backend`), so the
30
+ * host page is a thin mount.
31
+ */
32
+ export interface PlatformHomologacaoProps {
33
+ /** The recorded outcome; null renders the honest "não solicitada". */
34
+ record: PlatformHomologationRecordView | null;
35
+ /** The paste-ready answers, computed by the host's backend. */
36
+ guide: HomologacaoGuide;
37
+ /** Record the outcome — the host PUTs it and refreshes `record`. */
38
+ onSaveRecord: (input: HomologacaoSaveInput) => void;
39
+ /** The host's save-mutation state. */
40
+ save: HomologacaoSaveState;
41
+ /**
42
+ * Generate AND deliver the evidence file (the host downloads what its anexo
43
+ * route answers). Reject with an Error whose message names the reason —
44
+ * e.g. the missing platform sandbox token and where to fix it — and the
45
+ * card shows it verbatim.
46
+ */
47
+ onGenerateAnexo: () => Promise<void>;
48
+ }
49
+
50
+ /** The evidence-file half: real sandbox calls, downloaded as a text file. */
51
+ function AnexoCard({ onGenerate }: { onGenerate: () => Promise<void> }): ReactNode {
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [busy, setBusy] = useState(false);
54
+
55
+ const generate = async (): Promise<void> => {
56
+ setBusy(true);
57
+ setError(null);
58
+ try {
59
+ await onGenerate();
60
+ } catch (cause) {
61
+ setError(cause instanceof Error ? cause.message : 'Não foi possível gerar o anexo.');
62
+ } finally {
63
+ setBusy(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <Stack spacing={1.5} data-testid="homologacao-anexo-card" sx={CARD_SX}>
69
+ <Typography variant="body2" fontWeight={600}>
70
+ Anexo de evidências
71
+ </Typography>
72
+ <Typography variant="body2" color="text.secondary" component="p">
73
+ O formulário exige os requests e responses das requisições enviadas às APIs do
74
+ PagBank. O botão abaixo faz as chamadas reais no ambiente de testes (Sandbox) com o
75
+ token da própria plataforma — nada é cobrado de verdade — e baixa o arquivo pronto
76
+ para anexar, com o token redigido.
77
+ </Typography>
78
+ <Box>
79
+ <Button
80
+ variant="outlined"
81
+ size="small"
82
+ disabled={busy}
83
+ onClick={() => void generate()}
84
+ data-testid="homologacao-anexo-button"
85
+ >
86
+ Gerar anexo
87
+ </Button>
88
+ </Box>
89
+ {error !== null ? (
90
+ <Alert severity="error" data-testid="homologacao-anexo-error">
91
+ {error}
92
+ </Alert>
93
+ ) : null}
94
+ </Stack>
95
+ );
96
+ }
97
+
98
+ export function PlatformHomologacao(props: PlatformHomologacaoProps): ReactNode {
99
+ const { record, guide, onSaveRecord, save, onGenerateAnexo } = props;
100
+ return (
101
+ <Stack spacing={2} data-testid="platform-homologacao">
102
+ <HomologacaoOutcomeCard record={record} onSave={onSaveRecord} save={save} />
103
+ <HomologacaoGuideCard guide={guide} />
104
+ <AnexoCard onGenerate={onGenerateAnexo} />
105
+ </Stack>
106
+ );
107
+ }
@@ -96,6 +96,7 @@ function buildCheckout(
96
96
  providerConfig={config}
97
97
  tenantSlug={tenantSlug}
98
98
  confirmationExtra={runtime.config.confirmation?.extra}
99
+ validateApplePayMerchant={ports.validateApplePayMerchant}
99
100
  />
100
101
  );
101
102
  }
@@ -74,6 +74,13 @@ export interface CheckoutPorts {
74
74
  * some hosts must log or confirm the departure.
75
75
  */
76
76
  navigate?(url: string): void;
77
+ /**
78
+ * Apple Pay merchant validation (FUT-472): exchange the session's
79
+ * `validationURL` for an Apple merchant session, SERVER-SIDE — the merchant
80
+ * identity certificate must never reach a browser. Optional; without it the
81
+ * Apple Pay sheet cannot start and the card form remains the way to pay.
82
+ */
83
+ validateApplePayMerchant?(validationURL: string): Promise<unknown>;
77
84
  /** The remedy shown on the no-provider screen, AND the host's veto. */
78
85
  useAvailability?(): CheckoutAvailability;
79
86
  }
package/src/index.ts CHANGED
@@ -97,7 +97,16 @@ export {
97
97
  type GooglePaymentsClient,
98
98
  type GooglePayGatewayParams,
99
99
  } from './components/checkout/google-pay-button';
100
- export { googlePayConfig } from './components/checkout/method-capability';
100
+ export {
101
+ ApplePayButton,
102
+ applePaySupported,
103
+ APPLE_PAY_SUPPORTED_NETWORKS,
104
+ type ApplePayButtonProps,
105
+ type ApplePayPaymentRequest,
106
+ type ApplePaySessionClass,
107
+ type ApplePaySessionLike,
108
+ } from './components/checkout/apple-pay-button';
109
+ export { applePayDeclared, googlePayConfig } from './components/checkout/method-capability';
101
110
  export {
102
111
  CheckoutComponentsProvider,
103
112
  type CheckoutActionBarProps,
@@ -185,6 +194,28 @@ export {
185
194
  type PaymentProviderSettingsProps,
186
195
  } from './components/PaymentProviderSettings';
187
196
 
197
+ // ---------------------------------------------------------------------------
198
+ // The PLATFORM operations screens (FUT-479 / FUT-483, packaged by FUT-573) —
199
+ // the Connect-application consult and the homologação, as dumb components a
200
+ // host page mounts with data + callbacks from its own routes. Their backend
201
+ // halves live in `@12-apps/payments-backend` (`consultConnectApplications`,
202
+ // `platformHomologacaoGuide`, `createHomologationRecordService`,
203
+ // `buildPlatformHomologacaoAnexo`).
204
+ // ---------------------------------------------------------------------------
205
+ export {
206
+ ConnectApplicationPanel,
207
+ type ConnectApplicationPanelProps,
208
+ } from './components/platform/ConnectApplicationPanel';
209
+ export {
210
+ PlatformHomologacao,
211
+ type PlatformHomologacaoProps,
212
+ } from './components/platform/PlatformHomologacao';
213
+ export {
214
+ type HomologacaoSaveInput,
215
+ type HomologacaoSaveState,
216
+ type PlatformHomologationRecordView,
217
+ } from './components/platform/HomologacaoOutcomeCard';
218
+
188
219
  /**
189
220
  * Re-exported because it appears in the `prepareConnect` prop a host must
190
221
  * implement: without it the host could not type its own callback without