@12-apps/payments-frontend 1.17.0 → 1.19.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/client-context.tsx +6 -1
- 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/transport.ts +64 -4
- 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/copy.ts +28 -4
- package/src/flows/create-payment-flows.tsx +5 -1
- package/src/flows/screens-vault.tsx +280 -0
- package/src/flows/types.ts +20 -1
- package/src/flows/use-add-card.ts +183 -0
- package/src/index.ts +35 -1
|
@@ -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
|
+
}
|