@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "1.16.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.16.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.18.0",
32
- "@12-apps/typescript-config": "^1.18.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 ? (
@@ -20,6 +20,7 @@ import { createContext, useContext, type JSX, type ReactNode } from "react";
20
20
 
21
21
  import {
22
22
  chargeCard,
23
+ chargeWallet,
23
24
  fetchCheckoutConfig,
24
25
  listSavedCards,
25
26
  pollOrderStatus,
@@ -32,6 +33,7 @@ const DEFAULT_CLIENT: CheckoutClient = {
32
33
  getConfig: (tenantSlug) => fetchCheckoutConfig(tenantSlug),
33
34
  getStatus: (ref) => pollOrderStatus(ref),
34
35
  charge: (input) => chargeCard(input),
36
+ chargeWallet: (input) => chargeWallet(input),
35
37
  listInstruments: (tenantSlug) => listSavedCards(tenantSlug),
36
38
  refreshBrowserKey: (input) => refreshCardPublicKey(input),
37
39
  };
@@ -28,6 +28,7 @@ import { createCheckoutClient } from "./transport";
28
28
  import type {
29
29
  ChargeCardInput,
30
30
  ChargeOutcome,
31
+ ChargeWalletInput,
31
32
  CheckoutProviderConfig,
32
33
  OrderStatus,
33
34
  } from "./types";
@@ -77,6 +78,15 @@ export async function chargeCard(input: ChargeCardInput): Promise<Result<ChargeO
77
78
  return defaultClient.charge(input);
78
79
  }
79
80
 
81
+ /**
82
+ * Charge a wallet-minted instrument against an order (FUT-471/472) — the same
83
+ * `/charge` route as {@link chargeCard}, carrying `wallet: { type, key }` in
84
+ * place of a card token.
85
+ */
86
+ export async function chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>> {
87
+ return defaultClient.chargeWallet(input);
88
+ }
89
+
80
90
  /** List saved cards available for reuse (empty on any error — non-blocking). */
81
91
  export async function listSavedCards(tenantSlug?: string): Promise<SavedCard[]> {
82
92
  return defaultClient.listInstruments(tenantSlug);
@@ -0,0 +1,263 @@
1
+ import { Box } from "@mui/material";
2
+ import { useEffect, useRef, useState, type JSX } from "react";
3
+
4
+ import type { CheckoutOrder } from "./types";
5
+
6
+ /**
7
+ * The Google-branded pay button (FUT-471), per Google's four-step web guide —
8
+ * and ONLY the four steps. This component owns token ACQUISITION: load
9
+ * `pay.js`, construct a `PaymentsClient`, gate rendering on `isReadyToPay`,
10
+ * render the button Google's brand rules require via `createButton`, and turn
11
+ * `loadPaymentData` into the wallet key
12
+ * (`paymentData.paymentMethodData.tokenizationData.token`). What happens to
13
+ * the key — the charge, the polling, the outcome — belongs to the pane above
14
+ * (`wallet-pane.tsx`), so this file never talks to the wire.
15
+ *
16
+ * The `tokenizationSpecification` is `{ type: 'PAYMENT_GATEWAY', gateway,
17
+ * gatewayMerchantId }`, both parameters published by the store's chain head
18
+ * (`googlePayConfig`) — no vendor name is spelled here.
19
+ */
20
+
21
+ /** The slice of Google's `PaymentsClient` this button drives. */
22
+ export interface GooglePaymentsClient {
23
+ isReadyToPay(request: Record<string, unknown>): Promise<{ result: boolean }>;
24
+ createButton(options: {
25
+ onClick: () => void;
26
+ buttonSizeMode?: string;
27
+ buttonLocale?: string;
28
+ }): HTMLElement;
29
+ loadPaymentData(request: Record<string, unknown>): Promise<GooglePaymentData>;
30
+ }
31
+
32
+ /** The one path of the payment data this checkout reads. */
33
+ export interface GooglePaymentData {
34
+ paymentMethodData: { tokenizationData: { token: string } };
35
+ }
36
+
37
+ /** The `google.payments.api` namespace, as far as this button needs it. */
38
+ export interface GooglePayApi {
39
+ PaymentsClient: new (options: { environment: "TEST" | "PRODUCTION" }) => GooglePaymentsClient;
40
+ }
41
+
42
+ /** What the store's chain head published for the tokenizationSpecification. */
43
+ export interface GooglePayGatewayParams {
44
+ gateway: string;
45
+ gatewayMerchantId: string;
46
+ }
47
+
48
+ /** Google's script, loaded once per page. */
49
+ const PAY_JS_URL = "https://pay.google.com/gp/p/js/pay.js";
50
+
51
+ /**
52
+ * Card networks offered to Google: the intersection of Google's
53
+ * `allowedCardNetworks` enum and what PagBank's card acquiring processes.
54
+ * A network the gateway would refuse must not be offered on the sheet.
55
+ */
56
+ const ALLOWED_CARD_NETWORKS = ["AMEX", "ELO", "MASTERCARD", "VISA"];
57
+
58
+ /** Both auth methods of Google's guide: tokenized device cards and PAN_ONLY. */
59
+ const ALLOWED_AUTH_METHODS = ["PAN_ONLY", "CRYPTOGRAM_3DS"];
60
+
61
+ /** The `google.payments.api` global, when a script (or a harness) installed it. */
62
+ function installedApi(): GooglePayApi | null {
63
+ if (typeof window === "undefined") return null;
64
+ const scope = window as unknown as { google?: { payments?: { api?: GooglePayApi } } };
65
+ return scope.google?.payments?.api ?? null;
66
+ }
67
+
68
+ /** The in-flight (or settled) pay.js load — one script tag per page, ever. */
69
+ const loader: { pending: Promise<GooglePayApi | null> | null } = { pending: null };
70
+
71
+ /**
72
+ * Step 1 of the guide: load `pay.js` and hand back the API namespace. Answers
73
+ * `null` — never throws — when the script cannot load: an offline CDN must
74
+ * degrade to "no button", not to a crashed checkout. A pre-installed global
75
+ * (another button on the page, or an e2e harness) is used without a network
76
+ * request.
77
+ */
78
+ function loadGooglePayApi(): Promise<GooglePayApi | null> {
79
+ const installed = installedApi();
80
+ if (installed) return Promise.resolve(installed);
81
+ if (typeof document === "undefined") return Promise.resolve(null);
82
+ loader.pending ??= new Promise((resolve) => {
83
+ const script = document.createElement("script");
84
+ script.src = PAY_JS_URL;
85
+ script.async = true;
86
+ script.onload = () => resolve(installedApi());
87
+ script.onerror = () => resolve(null);
88
+ document.head.appendChild(script);
89
+ });
90
+ return loader.pending;
91
+ }
92
+
93
+ /** Step 2's probe: may this browser/device pay at all? */
94
+ function isReadyToPayRequest(): Record<string, unknown> {
95
+ return {
96
+ apiVersion: 2,
97
+ apiVersionMinor: 0,
98
+ allowedPaymentMethods: [
99
+ {
100
+ type: "CARD",
101
+ parameters: {
102
+ allowedAuthMethods: ALLOWED_AUTH_METHODS,
103
+ allowedCardNetworks: ALLOWED_CARD_NETWORKS,
104
+ },
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ /** Step 4's request: the same card method, now carrying gateway + price. */
111
+ function paymentDataRequest(
112
+ params: GooglePayGatewayParams,
113
+ order: CheckoutOrder,
114
+ ): Record<string, unknown> {
115
+ return {
116
+ apiVersion: 2,
117
+ apiVersionMinor: 0,
118
+ allowedPaymentMethods: [
119
+ {
120
+ type: "CARD",
121
+ parameters: {
122
+ allowedAuthMethods: ALLOWED_AUTH_METHODS,
123
+ allowedCardNetworks: ALLOWED_CARD_NETWORKS,
124
+ },
125
+ tokenizationSpecification: {
126
+ type: "PAYMENT_GATEWAY",
127
+ parameters: {
128
+ gateway: params.gateway,
129
+ gatewayMerchantId: params.gatewayMerchantId,
130
+ },
131
+ },
132
+ },
133
+ ],
134
+ transactionInfo: {
135
+ totalPriceStatus: "FINAL",
136
+ // Integer cents to Google's decimal string — the one money conversion
137
+ // in this file, from the server-authoritative order total.
138
+ totalPrice: (order.totalCents / 100).toFixed(2),
139
+ currencyCode: "BRL",
140
+ countryCode: "BR",
141
+ },
142
+ };
143
+ }
144
+
145
+ /** The buyer closed the sheet — a choice, not a failure to report. */
146
+ function sheetDismissed(error: unknown): boolean {
147
+ return (
148
+ typeof error === "object" &&
149
+ error !== null &&
150
+ (error as { statusCode?: unknown }).statusCode === "CANCELED"
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Resolve the client and ask `isReadyToPay` — the gate that decides whether
156
+ * the button exists at all. `api` is injectable for tests and harnesses;
157
+ * `undefined` means "load pay.js".
158
+ */
159
+ function useGooglePayClient(
160
+ api: GooglePayApi | null | undefined,
161
+ environment: "TEST" | "PRODUCTION",
162
+ ): GooglePaymentsClient | null {
163
+ const [client, setClient] = useState<GooglePaymentsClient | null>(null);
164
+ useEffect(() => {
165
+ const alive = { current: true };
166
+ void (api === undefined ? loadGooglePayApi() : Promise.resolve(api)).then((resolved) => {
167
+ if (!alive.current || !resolved) return;
168
+ const paymentsClient = new resolved.PaymentsClient({ environment });
169
+ paymentsClient
170
+ .isReadyToPay(isReadyToPayRequest())
171
+ .then((answer) => {
172
+ if (alive.current && answer.result) setClient(paymentsClient);
173
+ })
174
+ .catch(() => undefined);
175
+ });
176
+ return () => {
177
+ alive.current = false;
178
+ };
179
+ }, [api, environment]);
180
+ return client;
181
+ }
182
+
183
+ export interface GooglePayButtonProps {
184
+ order: CheckoutOrder;
185
+ /** The chain head's published gateway parameters (`googlePayConfig`). */
186
+ params: GooglePayGatewayParams;
187
+ /** The sheet resolved — charge this key. */
188
+ onKey: (key: string) => void;
189
+ /** The sheet failed for a reason worth telling the buyer (not a dismissal). */
190
+ onError: (message: string) => void;
191
+ /**
192
+ * Google's environment. Defaults to TEST — production requires the external
193
+ * Google Pay registration (see the ticket), and TEST tokens exercise the
194
+ * whole path against PagBank's sandbox with fictitious instruments.
195
+ */
196
+ environment?: "TEST" | "PRODUCTION";
197
+ /** Injectable API namespace for tests/harnesses; omit to load pay.js. */
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;
204
+ }
205
+
206
+ /**
207
+ * Renders NOTHING until `isReadyToPay` says this browser can pay — per the
208
+ * guide, the buyer must never see a Google Pay button that cannot work. The
209
+ * button element itself comes from `createButton` (brand rules); this
210
+ * component only gives it a mount point.
211
+ */
212
+ export function GooglePayButton({
213
+ order,
214
+ params,
215
+ onKey,
216
+ onError,
217
+ environment = "TEST",
218
+ api,
219
+ onReady,
220
+ }: GooglePayButtonProps): JSX.Element | null {
221
+ const client = useGooglePayClient(api, environment);
222
+ const container = useRef<HTMLDivElement | null>(null);
223
+ // The latest handlers/order, so the Google-rendered button — mounted once —
224
+ // never closes over a stale charge target.
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]);
231
+
232
+ useEffect(() => {
233
+ const mount = container.current;
234
+ if (!client || !mount) return undefined;
235
+ const button = client.createButton({
236
+ onClick: () => {
237
+ const { order: forOrder, params: forParams, onKey: emit, onError: fail } = current.current;
238
+ client
239
+ .loadPaymentData(paymentDataRequest(forParams, forOrder))
240
+ .then((data) => emit(data.paymentMethodData.tokenizationData.token))
241
+ .catch((error: unknown) => {
242
+ if (sheetDismissed(error)) return;
243
+ fail("Não foi possível concluir o pagamento com o Google Pay. Tente novamente ou pague com cartão.");
244
+ });
245
+ },
246
+ buttonSizeMode: "fill",
247
+ buttonLocale: "pt",
248
+ });
249
+ mount.replaceChildren(button);
250
+ return () => {
251
+ mount.replaceChildren();
252
+ };
253
+ }, [client]);
254
+
255
+ if (!client) return null;
256
+ return (
257
+ <Box
258
+ ref={container}
259
+ data-testid="google-pay-button"
260
+ sx={{ minHeight: 40, "& > *": { width: "100%" } }}
261
+ />
262
+ );
263
+ }