@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 +4 -4
- package/src/components/ProviderConnection.tsx +54 -0
- package/src/components/checkout/apple-pay-button.tsx +189 -0
- package/src/components/checkout/checkout-flow.tsx +9 -1
- package/src/components/checkout/checkout-steps.tsx +7 -0
- package/src/components/checkout/google-pay-button.tsx +12 -2
- package/src/components/checkout/method-capability.ts +18 -0
- package/src/components/checkout/providers/types.ts +8 -0
- package/src/components/checkout/use-wallet-charge.ts +12 -6
- package/src/components/checkout/wallet-pane.tsx +50 -24
- package/src/components/platform/ConnectApplicationPanel.tsx +77 -0
- package/src/components/platform/ConnectEnvironmentCard.tsx +175 -0
- package/src/components/platform/HomologacaoGuideCard.tsx +86 -0
- package/src/components/platform/HomologacaoOutcomeCard.tsx +170 -0
- package/src/components/platform/PlatformHomologacao.tsx +107 -0
- package/src/flows/create-payment-flows.tsx +1 -0
- package/src/flows/types.ts +7 -0
- package/src/index.ts +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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.
|
|
32
|
-
"@12-apps/typescript-config": "^1.
|
|
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
|
-
/**
|
|
43
|
-
|
|
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<
|
|
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 {
|
|
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
|
|
17
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
+
}
|
package/src/flows/types.ts
CHANGED
|
@@ -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 {
|
|
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
|