@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.
@@ -1,9 +1,15 @@
1
1
  import { Box, Divider } from "@mui/material";
2
2
  import { useState, type JSX } from "react";
3
3
 
4
+ import { ApplePayButton, applePaySupported } from "./apple-pay-button";
4
5
  import { CardView } from "./card-view";
5
6
  import { GooglePayButton } from "./google-pay-button";
6
- import { cardChain, cardTokenization, googlePayConfig } from "./method-capability";
7
+ import {
8
+ applePayDeclared,
9
+ cardChain,
10
+ cardTokenization,
11
+ googlePayConfig,
12
+ } from "./method-capability";
7
13
  import type { ProviderCheckoutScreenProps } from "./providers/types";
8
14
  import { useCheckoutComponents } from "./ui";
9
15
  import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
@@ -13,9 +19,9 @@ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
13
19
  *
14
20
  * A wallet is not a fourth method — it is another way of producing the CARD
15
21
  * instrument — so it renders INSIDE the card pane, above the form, and only
16
- * when the chain head both declared the wallet capability and published the
17
- * parameters the browser needs (`googlePayConfig`, fail-closed). A store with
18
- * no wallet renders exactly the card view it always did.
22
+ * when the chain head declared the wallet capability (both gates fail closed:
23
+ * `googlePayConfig` / `applePayDeclared` + the device's own support). A store
24
+ * with no wallet renders exactly the card view it always did.
19
25
  *
20
26
  * One pane owns BOTH submit paths' visibility so they cannot invite a double
21
27
  * payment: while a wallet charge is in flight or being confirmed, the card
@@ -23,6 +29,11 @@ import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
23
29
  * rule `card-view.tsx` applies to its own pay bar, one level up.
24
30
  */
25
31
 
32
+ /** The screen props narrowed to a raised order — what this pane requires. */
33
+ type WalletPaneProps = ProviderCheckoutScreenProps & {
34
+ order: NonNullable<ProviderCheckoutScreenProps["order"]>;
35
+ };
36
+
26
37
  /** Post-submit confirmation, error > timeout > spinner — the card view's order. */
27
38
  function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
28
39
  const { Alert, LoadingState } = useCheckoutComponents();
@@ -79,48 +90,63 @@ function WalletUnresolved({ message }: { message: string }): JSX.Element {
79
90
 
80
91
  /**
81
92
  * The wallet buttons the store's chain head supports, or null when there are
82
- * none. Split out so the pane below stays under the function-size gate.
93
+ * none to offer. The divider renders only once SOMETHING sits above it: Apple
94
+ * availability is known synchronously (feature-detect), Google's arrives when
95
+ * `isReadyToPay` approves (`onReady`) — a bare "ou pague com cartão" with
96
+ * nothing above it would caption an empty space.
83
97
  */
84
98
  function WalletButtons({
85
99
  props,
86
100
  wallet,
87
101
  onSheetError,
88
102
  }: {
89
- props: ProviderCheckoutScreenProps & { order: NonNullable<ProviderCheckoutScreenProps["order"]> };
103
+ props: WalletPaneProps;
90
104
  wallet: WalletCharge;
91
105
  onSheetError: (message: string) => void;
92
106
  }): JSX.Element | null {
93
107
  const { Text } = useCheckoutComponents();
108
+ const [googleReady, setGoogleReady] = useState(false);
94
109
  const googlePay = googlePayConfig(props.config);
95
- if (!googlePay) return null;
110
+ const applePay = applePayDeclared(props.config) && applePaySupported();
111
+ if (!googlePay && !applePay) return null;
96
112
  return (
97
113
  <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
98
- <GooglePayButton
99
- order={props.order}
100
- params={googlePay}
101
- onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
102
- onError={onSheetError}
103
- />
104
- <Divider>
105
- <Text variant="caption" size="xs" color="secondary" as="span">
106
- ou pague com cartão
107
- </Text>
108
- </Divider>
114
+ {applePay ? (
115
+ <ApplePayButton
116
+ order={props.order}
117
+ onAuthorized={(key) => wallet.payWithKey("APPLE_PAY", key)}
118
+ onError={onSheetError}
119
+ validateMerchant={props.validateApplePayMerchant}
120
+ />
121
+ ) : null}
122
+ {googlePay ? (
123
+ <GooglePayButton
124
+ order={props.order}
125
+ params={googlePay}
126
+ onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
127
+ onError={onSheetError}
128
+ onReady={() => setGoogleReady(true)}
129
+ />
130
+ ) : null}
131
+ {applePay || googleReady ? (
132
+ <Divider>
133
+ <Text variant="caption" size="xs" color="secondary" as="span">
134
+ ou pague com cartão
135
+ </Text>
136
+ </Divider>
137
+ ) : null}
109
138
  </Box>
110
139
  );
111
140
  }
112
141
 
113
142
  /** The CARD pane: wallet fast lane above, the card form below. */
114
- export function WalletCardPane(
115
- props: ProviderCheckoutScreenProps & {
116
- order: NonNullable<ProviderCheckoutScreenProps["order"]>;
117
- },
118
- ): JSX.Element {
143
+ export function WalletCardPane(props: WalletPaneProps): JSX.Element {
119
144
  const { Alert } = useCheckoutComponents();
120
145
  const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
121
146
  const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs);
122
147
  // A sheet failure the wallet reported before any charge existed (pay.js
123
- // refused, the sheet errored) — shown beside the form, which stays usable.
148
+ // refused, merchant validation unavailable, the sheet errored) — shown
149
+ // beside the form, which stays usable.
124
150
  const [sheetError, setSheetError] = useState<string | null>(null);
125
151
 
126
152
  if (wallet.phase !== "idle") return <WalletProcessing wallet={wallet} />;
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { Box, Button, Stack, Typography } from '@mui/material';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import type { ConnectApplicationReport, PaymentEnvironment } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX, ConnectEnvironmentCard } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The platform's PagBank Connect application, per environment (FUT-479,
12
+ * packaged by FUT-573).
13
+ *
14
+ * The application every store authorizes against is registered by hand, so
15
+ * nothing in the product could say what is registered, in which environment,
16
+ * or with which redirect URI. This panel is the consult
17
+ * (`GET /oauth2/application/{client_id}`) made permanent: per environment —
18
+ * sandbox and produção are separate applications — it shows what PagBank has
19
+ * on file, including the exact redirect_uri, and flags a mismatch against the
20
+ * callback the deployment actually uses (a mismatch is a silent OAuth
21
+ * failure). Read-only: creating an application stays a deliberate manual act.
22
+ *
23
+ * Dumb by design: the HOST fetches the report from its own mounted route
24
+ * (`consultConnectApplications` in `@12-apps/payments-backend`) and passes it
25
+ * here, so the host page is a thin mount — page chrome, auth and loading
26
+ * belong to the host; the screen itself lives in this package.
27
+ */
28
+ export interface ConnectApplicationPanelProps {
29
+ /** The consult report, as the backend's `consultConnectApplications` answers. */
30
+ report: ConnectApplicationReport;
31
+ /** Re-run the consult. Omitted, the refresh button is not rendered. */
32
+ onRefresh?: () => void;
33
+ /**
34
+ * Which host-side variables feed one environment's application — the host's
35
+ * own configuration surface, rendered as a collapsible per-environment help
36
+ * when provided.
37
+ */
38
+ configVarsFor?: (environment: PaymentEnvironment) => string[];
39
+ }
40
+
41
+ export function ConnectApplicationPanel(props: ConnectApplicationPanelProps): ReactNode {
42
+ const { report, onRefresh, configVarsFor } = props;
43
+ return (
44
+ <Stack spacing={2} data-testid="connect-application-panel">
45
+ <Stack spacing={0.5} data-testid="connect-expected-redirect" sx={CARD_SX}>
46
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
47
+ Callback desta instalação (o valor que precisa estar registrado)
48
+ </Typography>
49
+ <Box
50
+ component="code"
51
+ sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
52
+ >
53
+ {report.expectedRedirectUri}
54
+ </Box>
55
+ </Stack>
56
+ {report.environments.map((status) => (
57
+ <ConnectEnvironmentCard
58
+ key={status.environment}
59
+ status={status}
60
+ configVars={configVarsFor?.(status.environment)}
61
+ />
62
+ ))}
63
+ {onRefresh ? (
64
+ <Box>
65
+ <Button
66
+ variant="outlined"
67
+ size="small"
68
+ onClick={() => onRefresh()}
69
+ data-testid="connect-refresh"
70
+ >
71
+ Consultar novamente
72
+ </Button>
73
+ </Box>
74
+ ) : null}
75
+ </Stack>
76
+ );
77
+ }
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Stack, Typography } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import type { ConnectApplicationStatus, PaymentEnvironment } from '@12-apps/payments-backend';
7
+
8
+ /**
9
+ * One environment's Connect application (FUT-479, packaged by FUT-573).
10
+ * Sandbox and produção are SEPARATE applications with separate id/secret
11
+ * pairs, so each card stands on its own: configured or not, what PagBank says
12
+ * is registered, and whether the registered redirect URI matches the
13
+ * deployment's callback.
14
+ */
15
+
16
+ const ENV_LABEL: Record<PaymentEnvironment, string> = {
17
+ SANDBOX: 'Sandbox',
18
+ PRODUCTION: 'Produção',
19
+ };
20
+
21
+ /** The bordered-card look every block of these screens shares. */
22
+ export const CARD_SX = {
23
+ border: 1,
24
+ borderColor: 'divider',
25
+ borderRadius: 2,
26
+ p: 2,
27
+ } as const;
28
+
29
+ function Field({ label, children }: { label: string; children: ReactNode }): ReactNode {
30
+ return (
31
+ <Box
32
+ sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 180, wordBreak: 'break-all' }}
33
+ >
34
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
35
+ {label}
36
+ </Typography>
37
+ <Typography component="span" variant="body2">
38
+ {children}
39
+ </Typography>
40
+ </Box>
41
+ );
42
+ }
43
+
44
+ /** The verdict the screen exists for: does the registered callback match ours? */
45
+ function MismatchAlert({ status }: { status: ConnectApplicationStatus }): ReactNode {
46
+ if (status.application === null) return null;
47
+ if (status.redirectUriMismatch === true) {
48
+ return (
49
+ <Alert severity="error" data-testid={`connect-mismatch-${status.environment}`}>
50
+ A redirect_uri registrada no PagBank é diferente do callback desta instalação. O fluxo
51
+ de autorização OAuth falha silenciosamente até o cadastro ser corrigido no PagBank.
52
+ </Alert>
53
+ );
54
+ }
55
+ if (status.redirectUriMismatch === false) {
56
+ return (
57
+ <Alert severity="success" data-testid={`connect-match-${status.environment}`}>
58
+ A redirect_uri registrada confere com o callback desta instalação.
59
+ </Alert>
60
+ );
61
+ }
62
+ return (
63
+ <Alert severity="warning" data-testid={`connect-unknown-${status.environment}`}>
64
+ A resposta do PagBank não informou a redirect_uri — não foi possível comparar com o
65
+ callback desta instalação.
66
+ </Alert>
67
+ );
68
+ }
69
+
70
+ /** What PagBank reports as registered, plus whatever extra keys came back. */
71
+ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): ReactNode {
72
+ const app = status.application;
73
+ if (app === null) return null;
74
+ const extraKeys = Object.keys(app.extra);
75
+ return (
76
+ <Stack spacing={1.5}>
77
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5 }}>
78
+ <Field label="Nome (exibido ao lojista)">{app.name ?? '—'}</Field>
79
+ <Field label="Site">{app.site ?? '—'}</Field>
80
+ <Field label="Descrição">{app.description ?? '—'}</Field>
81
+ <Field label="Logo">{app.logo ?? '—'}</Field>
82
+ <Field label="redirect_uri registrada">{app.redirectUri ?? 'não informada'}</Field>
83
+ </Box>
84
+ {extraKeys.length > 0 ? (
85
+ <Box data-testid={`connect-extra-${status.environment}`}>
86
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
87
+ Outros campos retornados (schema não documentado)
88
+ </Typography>
89
+ <Box component="pre" sx={{ m: 0, fontSize: 12, overflowX: 'auto' }}>
90
+ {JSON.stringify(app.extra, null, 2)}
91
+ </Box>
92
+ </Box>
93
+ ) : null}
94
+ </Stack>
95
+ );
96
+ }
97
+
98
+ /**
99
+ * The collapsible "what feeds this environment" help. The variable names are
100
+ * the HOST's own configuration surface, so they arrive via `configVars`; with
101
+ * none provided the toggle is omitted entirely rather than opening on nothing.
102
+ */
103
+ function ConfigHelp({
104
+ environment,
105
+ configVars,
106
+ }: {
107
+ environment: PaymentEnvironment;
108
+ configVars?: string[];
109
+ }): ReactNode {
110
+ const [open, setOpen] = useState(false);
111
+ if (!configVars || configVars.length === 0) return null;
112
+ return (
113
+ <Stack spacing={1} alignItems="flex-start">
114
+ <Button
115
+ variant="outlined"
116
+ size="small"
117
+ onClick={() => setOpen((value) => !value)}
118
+ data-testid={`connect-config-toggle-${environment}`}
119
+ >
120
+ {open ? 'Ocultar variáveis de ambiente' : 'Ver variáveis de ambiente'}
121
+ </Button>
122
+ {open ? (
123
+ <Box data-testid={`connect-config-details-${environment}`}>
124
+ <Typography variant="caption" color="text.secondary" component="p">
125
+ A aplicação deste ambiente é resolvida estritamente por estas variáveis (sem
126
+ fallback entre ambientes):
127
+ </Typography>
128
+ <Box component="ul" sx={{ m: 0, pl: 2.5 }}>
129
+ {configVars.map((name) => (
130
+ <Box component="li" key={name}>
131
+ <Box component="code" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
132
+ {name}
133
+ </Box>
134
+ </Box>
135
+ ))}
136
+ </Box>
137
+ </Box>
138
+ ) : null}
139
+ </Stack>
140
+ );
141
+ }
142
+
143
+ export function ConnectEnvironmentCard({
144
+ status,
145
+ configVars,
146
+ }: {
147
+ status: ConnectApplicationStatus;
148
+ configVars?: string[];
149
+ }): ReactNode {
150
+ return (
151
+ <Stack spacing={1.5} data-testid={`connect-env-${status.environment}`} sx={CARD_SX}>
152
+ <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, wordBreak: 'break-all' }}>
153
+ <Typography variant="body2" fontWeight={600}>
154
+ {ENV_LABEL[status.environment]}
155
+ </Typography>
156
+ <Typography component="span" variant="caption" color="text.secondary">
157
+ {status.clientId !== null ? `client_id: ${status.clientId}` : ''}
158
+ </Typography>
159
+ </Box>
160
+ {!status.configured ? (
161
+ <Typography variant="body2" color="text.secondary">
162
+ Nenhuma aplicação configurada neste ambiente.
163
+ </Typography>
164
+ ) : null}
165
+ {status.error !== null ? (
166
+ <Alert severity="warning" data-testid={`connect-error-${status.environment}`}>
167
+ {status.error}
168
+ </Alert>
169
+ ) : null}
170
+ <MismatchAlert status={status} />
171
+ <ApplicationFields status={status} />
172
+ <ConfigHelp environment={status.environment} configVars={configVars} />
173
+ </Stack>
174
+ );
175
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import { Box, Link, Stack, Typography } from '@mui/material';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import type { HomologacaoGuide } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The paste-ready homologação answers (FUT-483, packaged by FUT-573) — the
12
+ * Pipefy form with every deployment-specific value already computed, and the
13
+ * services list naming BOTH Order and Connect.
14
+ */
15
+
16
+ /** One paste-ready value, in a copyable code block. */
17
+ function Answer({ label, children }: { label: string; children: ReactNode }): ReactNode {
18
+ return (
19
+ <Stack spacing={0.25}>
20
+ <Typography variant="caption" color="text.secondary" fontWeight={600}>
21
+ {label}
22
+ </Typography>
23
+ <Box
24
+ component="code"
25
+ sx={{
26
+ fontFamily: 'monospace',
27
+ fontSize: 12,
28
+ p: 1,
29
+ borderRadius: 1,
30
+ bgcolor: 'action.hover',
31
+ wordBreak: 'break-word',
32
+ whiteSpace: 'pre-wrap',
33
+ }}
34
+ >
35
+ {children}
36
+ </Box>
37
+ </Stack>
38
+ );
39
+ }
40
+
41
+ export function HomologacaoGuideCard({ guide }: { guide: HomologacaoGuide }): ReactNode {
42
+ return (
43
+ <Stack spacing={1.5} data-testid="homologacao-guide-card" sx={CARD_SX}>
44
+ <Typography variant="body2" fontWeight={600}>
45
+ Formulário de homologação — respostas prontas
46
+ </Typography>
47
+ <Typography variant="body2" color="text.secondary" component="p">
48
+ Abra o{' '}
49
+ <Link
50
+ href={guide.formUrl}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ data-testid="homologacao-form-link"
54
+ >
55
+ formulário oficial de homologação
56
+ </Link>{' '}
57
+ e preencha com os valores abaixo. Em paralelo, abra um chamado no{' '}
58
+ <Link href={guide.supportFormUrl} target="_blank" rel="noreferrer">
59
+ SIP — Suporte Integração PagBank
60
+ </Link>{' '}
61
+ citando o 403 ACCESS_DENIED — o que responder primeiro resolve a dúvida de o
62
+ formulário cobrir ou não o Connect. Documentação:{' '}
63
+ <Link href={guide.docsUrl} target="_blank" rel="noreferrer">
64
+ solicitar homologação
65
+ </Link>
66
+ .
67
+ </Typography>
68
+ <Answer label="Selecione o tipo de integração">{guide.integrationType}</Answer>
69
+ <Box data-testid="homologacao-services">
70
+ <Answer label="Selecione qual serviço você integrou (marque OS DOIS)">
71
+ {guide.services.join('\n')}
72
+ </Answer>
73
+ </Box>
74
+ <Answer label="Instruções de acesso ao seu ambiente (limite de 255 caracteres)">
75
+ {guide.accessInstructions}
76
+ </Answer>
77
+ <Answer label="URL do site">{guide.siteUrl}</Answer>
78
+ <Answer label="Detalhe quais produtos/serviços serão comercializados">
79
+ {guide.productsDescription}
80
+ </Answer>
81
+ <Typography variant="caption" color="text.secondary" component="p">
82
+ {guide.slaText}
83
+ </Typography>
84
+ </Stack>
85
+ );
86
+ }
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Chip, Stack, TextField, Typography } from '@mui/material';
4
+ import { useState, type ReactNode } from 'react';
5
+
6
+ import type { PlatformHomologationStatus } from '@12-apps/payments-backend';
7
+
8
+ import { CARD_SX } from './ConnectEnvironmentCard';
9
+
10
+ /**
11
+ * The homologação outcome record (FUT-483, packaged by FUT-573) — the durable
12
+ * answer to "is the platform homologated?". Absence of the record is the
13
+ * honest fourth state ("não solicitada"), which is why it is displayed but
14
+ * never offered as a choice.
15
+ */
16
+
17
+ /**
18
+ * One provider's recorded outcome as the wire carries it — the backend's
19
+ * `PlatformHomologationRecord` after JSON serialization (dates as ISO
20
+ * strings).
21
+ */
22
+ export interface PlatformHomologationRecordView {
23
+ provider: string;
24
+ status: PlatformHomologationStatus;
25
+ protocol: string | null;
26
+ notes: string | null;
27
+ submittedAt: string | null;
28
+ decidedAt: string | null;
29
+ updatedBy: string | null;
30
+ updatedAt: string;
31
+ }
32
+
33
+ /** What the outcome form submits — the whole record, replaced deliberately. */
34
+ export interface HomologacaoSaveInput {
35
+ status: PlatformHomologationStatus;
36
+ protocol: string;
37
+ notes: string;
38
+ }
39
+
40
+ /** The host's save-mutation state, whatever machinery produces it. */
41
+ export interface HomologacaoSaveState {
42
+ pending: boolean;
43
+ error: string | null;
44
+ success: boolean;
45
+ }
46
+
47
+ const STATUS_LABEL: Record<PlatformHomologationStatus, string> = {
48
+ SUBMITTED: 'Solicitada',
49
+ APPROVED: 'Aprovada',
50
+ REJECTED: 'Recusada',
51
+ };
52
+
53
+ const STATUS_COLOR: Record<PlatformHomologationStatus, 'warning' | 'success' | 'error'> = {
54
+ SUBMITTED: 'warning',
55
+ APPROVED: 'success',
56
+ REJECTED: 'error',
57
+ };
58
+
59
+ function StatusChip({ record }: { record: PlatformHomologationRecordView | null }): ReactNode {
60
+ if (record === null) {
61
+ return (
62
+ <Chip
63
+ label="Não solicitada"
64
+ size="small"
65
+ variant="outlined"
66
+ data-testid="homologacao-status-chip"
67
+ />
68
+ );
69
+ }
70
+ return (
71
+ <Chip
72
+ label={STATUS_LABEL[record.status]}
73
+ size="small"
74
+ color={STATUS_COLOR[record.status]}
75
+ data-testid="homologacao-status-chip"
76
+ />
77
+ );
78
+ }
79
+
80
+ const formatDateTime = (iso: string): string => new Date(iso).toLocaleString('pt-BR');
81
+
82
+ function RecordTrail({ record }: { record: PlatformHomologationRecordView }): ReactNode {
83
+ return (
84
+ <Typography variant="caption" color="text.secondary" component="p">
85
+ {record.submittedAt ? `Solicitada em ${formatDateTime(record.submittedAt)}. ` : ''}
86
+ {record.decidedAt ? `Decidida em ${formatDateTime(record.decidedAt)}. ` : ''}
87
+ {record.updatedBy ? `Registrado por ${record.updatedBy}.` : ''}
88
+ </Typography>
89
+ );
90
+ }
91
+
92
+ interface HomologacaoOutcomeCardProps {
93
+ record: PlatformHomologationRecordView | null;
94
+ /** Record the outcome — the host PUTs it and refreshes `record`. */
95
+ onSave: (input: HomologacaoSaveInput) => void;
96
+ save: HomologacaoSaveState;
97
+ }
98
+
99
+ export function HomologacaoOutcomeCard(props: HomologacaoOutcomeCardProps): ReactNode {
100
+ const { record, onSave, save } = props;
101
+ const [status, setStatus] = useState<PlatformHomologationStatus>(record?.status ?? 'SUBMITTED');
102
+ const [protocol, setProtocol] = useState(record?.protocol ?? '');
103
+ const [notes, setNotes] = useState(record?.notes ?? '');
104
+
105
+ return (
106
+ <Stack spacing={1.5} data-testid="homologacao-outcome-card" sx={CARD_SX}>
107
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
108
+ <Typography variant="body2" fontWeight={600}>
109
+ Situação da homologação
110
+ </Typography>
111
+ <StatusChip record={record} />
112
+ </Box>
113
+ {record ? <RecordTrail record={record} /> : null}
114
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'flex-start' }}>
115
+ <TextField
116
+ select
117
+ size="small"
118
+ label="Situação"
119
+ value={status}
120
+ onChange={(event) => setStatus(event.target.value as PlatformHomologationStatus)}
121
+ slotProps={{
122
+ select: { native: true },
123
+ htmlInput: { 'data-testid': 'homologacao-status-select' },
124
+ }}
125
+ >
126
+ {(Object.keys(STATUS_LABEL) as PlatformHomologationStatus[]).map((key) => (
127
+ <option key={key} value={key}>
128
+ {STATUS_LABEL[key]}
129
+ </option>
130
+ ))}
131
+ </TextField>
132
+ <TextField
133
+ size="small"
134
+ aria-label="Protocolo"
135
+ placeholder="Protocolo (cartão do Pipefy / chamado)"
136
+ value={protocol}
137
+ onChange={(event) => setProtocol(event.target.value)}
138
+ slotProps={{ htmlInput: { 'data-testid': 'homologacao-protocol' } }}
139
+ />
140
+ <TextField
141
+ size="small"
142
+ aria-label="Observações"
143
+ placeholder="Observações (resposta do PagBank, contexto…)"
144
+ value={notes}
145
+ onChange={(event) => setNotes(event.target.value)}
146
+ slotProps={{ htmlInput: { 'data-testid': 'homologacao-notes' } }}
147
+ />
148
+ <Button
149
+ variant="contained"
150
+ size="small"
151
+ disabled={save.pending}
152
+ onClick={() => onSave({ status, protocol, notes })}
153
+ data-testid="homologacao-save"
154
+ >
155
+ Registrar
156
+ </Button>
157
+ </Box>
158
+ {save.error !== null ? (
159
+ <Alert severity="error" data-testid="homologacao-save-error">
160
+ {save.error}
161
+ </Alert>
162
+ ) : null}
163
+ {save.success ? (
164
+ <Alert severity="success" data-testid="homologacao-save-ok">
165
+ Registro atualizado.
166
+ </Alert>
167
+ ) : null}
168
+ </Stack>
169
+ );
170
+ }