@12-apps/mcp 1.0.0 → 1.3.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.
@@ -3,6 +3,7 @@ import type {
3
3
  GenerateOptions,
4
4
  JsonSchema,
5
5
  ParameterLocation,
6
+ ToolAnnotations,
6
7
  ToolParameter,
7
8
  } from "../types";
8
9
 
@@ -18,6 +19,10 @@ export interface OpenApiOperation {
18
19
  summary?: string;
19
20
  description?: string;
20
21
  tags?: string[];
22
+ /** Paladira's required projection of MCP tool annotations into OpenAPI. */
23
+ "x-mcp-tool-annotations"?: ToolAnnotations;
24
+ /** Dotted response paths stripped from the result before the agent sees it. */
25
+ "x-mcp-redact-response"?: readonly string[];
21
26
  parameters?: OpenApiParameter[];
22
27
  requestBody?: OpenApiRequestBody;
23
28
  responses?: Record<string, OpenApiResponse>;
@@ -45,7 +50,15 @@ export interface OpenApiDocument {
45
50
  security?: Array<Record<string, string[]>>;
46
51
  }
47
52
 
48
- const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const;
53
+ const HTTP_METHODS = [
54
+ "get",
55
+ "put",
56
+ "post",
57
+ "delete",
58
+ "patch",
59
+ "options",
60
+ "head",
61
+ ] as const;
49
62
  const MUTATING = new Set(["post", "put", "patch", "delete"]);
50
63
  const PARAM_LOCATIONS = new Set<ParameterLocation>(["path", "query", "header"]);
51
64
 
@@ -63,7 +76,9 @@ function slugify(method: string, path: string): string {
63
76
 
64
77
  function securityNames(op: OpenApiOperation, doc: OpenApiDocument): string[] {
65
78
  const requirements = op.security ?? doc.security ?? [];
66
- return [...new Set(requirements.flatMap((requirement) => Object.keys(requirement)))];
79
+ return [
80
+ ...new Set(requirements.flatMap((requirement) => Object.keys(requirement))),
81
+ ];
67
82
  }
68
83
 
69
84
  function bodySchema(op: OpenApiOperation): JsonSchema | undefined {
@@ -72,12 +87,37 @@ function bodySchema(op: OpenApiOperation): JsonSchema | undefined {
72
87
 
73
88
  function responseSchema(op: OpenApiOperation): JsonSchema | undefined {
74
89
  const responses = op.responses ?? {};
75
- const code = ["200", "201", "2XX", "default"].find((c) => responses[c]?.content?.[JSON_CONTENT]?.schema);
90
+ const code = ["200", "201", "2XX", "default"].find(
91
+ (c) => responses[c]?.content?.[JSON_CONTENT]?.schema,
92
+ );
76
93
  return code ? responses[code]?.content?.[JSON_CONTENT]?.schema : undefined;
77
94
  }
78
95
 
96
+ function annotations(op: OpenApiOperation, name: string): ToolAnnotations {
97
+ const value = op["x-mcp-tool-annotations"];
98
+ if (
99
+ !value ||
100
+ typeof value.readOnlyHint !== "boolean" ||
101
+ typeof value.openWorldHint !== "boolean" ||
102
+ typeof value.destructiveHint !== "boolean"
103
+ ) {
104
+ throw new Error(
105
+ `OpenAPI operation "${name}" must explicitly set readOnlyHint, openWorldHint, and destructiveHint`,
106
+ );
107
+ }
108
+ if (typeof value.title !== "string" || !value.title.trim()) {
109
+ throw new Error(
110
+ `OpenAPI operation "${name}" must set a human-readable annotations.title`,
111
+ );
112
+ }
113
+ return value;
114
+ }
115
+
79
116
  /** Path params are always required regardless of how the spec marks them. */
80
- function paramRequired(location: ParameterLocation, raw: OpenApiParameter): boolean {
117
+ function paramRequired(
118
+ location: ParameterLocation,
119
+ raw: OpenApiParameter,
120
+ ): boolean {
81
121
  return location === "path" ? true : Boolean(raw.required);
82
122
  }
83
123
 
@@ -111,11 +151,19 @@ interface BodyContribution {
111
151
  */
112
152
  function bodyContribution(op: OpenApiOperation): BodyContribution {
113
153
  const body = bodySchema(op);
114
- if (!body) return { properties: {}, bodyProps: [], requiredProps: [], bodyIsWhole: false };
154
+ if (!body)
155
+ return {
156
+ properties: {},
157
+ bodyProps: [],
158
+ requiredProps: [],
159
+ bodyIsWhole: false,
160
+ };
115
161
 
116
162
  const propSchemas = body.properties as Record<string, JsonSchema> | undefined;
117
163
  if (body.type === "object" && propSchemas) {
118
- const bodyRequired = new Set(Array.isArray(body.required) ? (body.required as string[]) : []);
164
+ const bodyRequired = new Set(
165
+ Array.isArray(body.required) ? (body.required as string[]) : [],
166
+ );
119
167
  const bodyProps = Object.keys(propSchemas);
120
168
  return {
121
169
  properties: propSchemas,
@@ -162,7 +210,12 @@ function buildInput(op: OpenApiOperation): BuiltInput {
162
210
  properties,
163
211
  ...(allRequired.length ? { required: allRequired } : {}),
164
212
  };
165
- return { inputSchema, parameters, bodyProps: body.bodyProps, bodyIsWhole: body.bodyIsWhole };
213
+ return {
214
+ inputSchema,
215
+ parameters,
216
+ bodyProps: body.bodyProps,
217
+ bodyIsWhole: body.bodyIsWhole,
218
+ };
166
219
  }
167
220
 
168
221
  /** The declared operations of a path item, as (method, operation) pairs. */
@@ -189,11 +242,16 @@ function buildTool(
189
242
  const { inputSchema, parameters, bodyProps, bodyIsWhole } = buildInput(op);
190
243
  return {
191
244
  name,
192
- description: op.summary ?? op.description ?? `${method.toUpperCase()} ${path}`,
245
+ description:
246
+ op.summary ?? op.description ?? `${method.toUpperCase()} ${path}`,
193
247
  method: method.toUpperCase(),
194
248
  path,
195
249
  inputSchema,
196
250
  outputSchema: responseSchema(op),
251
+ annotations: annotations(op, name),
252
+ ...(op["x-mcp-redact-response"]?.length
253
+ ? { redactResponse: op["x-mcp-redact-response"] }
254
+ : {}),
197
255
  parameters,
198
256
  bodyProps,
199
257
  bodyIsWhole,
@@ -0,0 +1,99 @@
1
+ import { Box } from "@12-apps/ui/mui/Box";
2
+ import { Text } from "@12-apps/ui/typography/Text";
3
+
4
+ import { AI_CAPABILITIES, type AiCapability } from "../guide";
5
+ import { CapabilityIcon } from "./ai-icons";
6
+
7
+ /** One marketing card: an icon, a headline and an example prompt (as a bubble). */
8
+ function CapabilityCard({ capability }: { capability: AiCapability }): React.JSX.Element {
9
+ return (
10
+ <Box
11
+ data-testid={`ai-capability-${capability.id}`}
12
+ sx={{
13
+ p: 2.5,
14
+ borderRadius: 3,
15
+ border: 1,
16
+ borderColor: "divider",
17
+ bgcolor: "background.paper",
18
+ height: "100%",
19
+ // Static transition string: safe to cross the server→client boundary.
20
+ transition: "border-color 200ms, box-shadow 200ms, transform 200ms",
21
+ "&:hover": { borderColor: "primary.main", boxShadow: 3, transform: "translateY(-3px)" },
22
+ }}
23
+ >
24
+ <Box
25
+ sx={{
26
+ width: 44,
27
+ height: 44,
28
+ borderRadius: 2,
29
+ display: "flex",
30
+ alignItems: "center",
31
+ justifyContent: "center",
32
+ bgcolor: "primary.main",
33
+ color: "primary.contrastText",
34
+ mb: 1.5,
35
+ }}
36
+ >
37
+ <CapabilityIcon id={capability.id} fontSize={26} />
38
+ </Box>
39
+ <Text variant="body" weight="bold" as="h3">
40
+ {capability.title}
41
+ </Text>
42
+ <Box
43
+ data-testid={`ai-capability-prompt-${capability.id}`}
44
+ sx={{
45
+ mt: 1,
46
+ px: 1.5,
47
+ py: 1,
48
+ borderRadius: 2,
49
+ borderBottomLeftRadius: 4,
50
+ bgcolor: "action.hover",
51
+ border: 1,
52
+ borderColor: "divider",
53
+ }}
54
+ >
55
+ <Text variant="body" size="sm" as="p" color="secondary">
56
+ {capability.detail}
57
+ </Text>
58
+ </Box>
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Marketing block for the AI integration: a headline + a responsive grid of
65
+ * capability cards. Shown on the onboarding landing (before the owner starts) to
66
+ * sell the feature — "here's what the assistant does for you". `capabilities`
67
+ * defaults to the shared set; apps can pass their own.
68
+ */
69
+ export function AiCapabilities({
70
+ capabilities = AI_CAPABILITIES,
71
+ }: {
72
+ capabilities?: readonly AiCapability[];
73
+ }): React.JSX.Element {
74
+ return (
75
+ <Box data-testid="ai-capability-examples" sx={{ width: "100%" }}>
76
+ <Box sx={{ mb: 3 }}>
77
+ <Text variant="heading" size="sm" as="h2">
78
+ O que o assistente faz por você
79
+ </Text>
80
+ <Box sx={{ mt: 0.5 }}>
81
+ <Text variant="body" size="sm" as="p" color="secondary">
82
+ Sem planilhas, sem cliques — é só perguntar na conversa.
83
+ </Text>
84
+ </Box>
85
+ </Box>
86
+ <Box
87
+ sx={{
88
+ display: "grid",
89
+ gap: 3,
90
+ gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "repeat(4, 1fr)" },
91
+ }}
92
+ >
93
+ {capabilities.map((capability) => (
94
+ <CapabilityCard key={capability.id} capability={capability} />
95
+ ))}
96
+ </Box>
97
+ </Box>
98
+ );
99
+ }
@@ -0,0 +1,96 @@
1
+ import { providerForHostId, type AiHostGuide, type AiProvider } from "../guide";
2
+
3
+ /**
4
+ * Pure helpers + the connection type shared by the AI-onboarding flow steps
5
+ * (`ai-flow-steps.tsx`) and the orchestration/status board (`ai-steps.tsx`),
6
+ * kept here so neither JSX module has to import the other (no import cycle).
7
+ */
8
+
9
+ /** The live connection surfaced from the server (empty list when none). */
10
+ export interface AiConnection {
11
+ clientName: string | null;
12
+ /**
13
+ * The provider this connection is attributed to (derived server-side from the
14
+ * OAuth client's redirect URIs / confirmed by `announceAiConnection`). `null`
15
+ * for a legacy connection recorded before attribution existed — matched
16
+ * best-effort to whichever host the owner is connecting.
17
+ */
18
+ host: AiProvider | null;
19
+ lastActiveAt: Date;
20
+ }
21
+
22
+ /** Friendly provider names for the connected-state title ("Claude Conectado"). */
23
+ const PROVIDER_LABEL: Record<AiProvider, string> = {
24
+ claude: "Claude",
25
+ chatgpt: "ChatGPT",
26
+ codex: "Codex",
27
+ };
28
+
29
+ /** Resolve a persisted host id to its guide (falls back to the first host). */
30
+ export function resolveHost(hosts: readonly AiHostGuide[], hostId: unknown): AiHostGuide {
31
+ const found = hosts.find((h) => h.id === hostId) ?? hosts[0];
32
+ if (!found) throw new Error("resolveHost: hosts array must not be empty");
33
+ return found;
34
+ }
35
+
36
+ /** Friendly label for a persisted host id (falls back to a generic word). */
37
+ export function hostLabel(hosts: readonly AiHostGuide[], hostId: unknown): string {
38
+ return hosts.find((h) => h.id === hostId)?.label ?? "seu assistente";
39
+ }
40
+
41
+ /**
42
+ * The live connection for a given host, or null. A connection is matched by its
43
+ * derived provider; a legacy `null`-host connection matches best-effort (so the
44
+ * pre-attribution single-connection behavior still works).
45
+ */
46
+ export function connectionForHost(
47
+ connections: readonly AiConnection[],
48
+ hostId: unknown,
49
+ ): AiConnection | null {
50
+ const provider = typeof hostId === "string" ? providerForHostId(hostId) : null;
51
+ if (!provider) return connections.find((c) => c.host === null) ?? connections[0] ?? null;
52
+ return (
53
+ connections.find((c) => c.host === provider) ?? connections.find((c) => c.host === null) ?? null
54
+ );
55
+ }
56
+
57
+ /** Short pt-BR "ativo há X" from a timestamp (agora / min / h / dias). */
58
+ export function activeAgo(date: Date): string {
59
+ const minutes = Math.max(0, Math.floor((Date.now() - date.getTime()) / 60_000));
60
+ if (minutes < 1) return "ativo agora";
61
+ if (minutes < 60) return `ativo há ${minutes} min`;
62
+ const hours = Math.floor(minutes / 60);
63
+ if (hours < 24) return `ativo há ${hours} h`;
64
+ return `ativo há ${Math.floor(hours / 24)} dias`;
65
+ }
66
+
67
+ /** The most-recently-active connection across all providers (null when none). */
68
+ function mostRecent(connections: readonly AiConnection[]): AiConnection | null {
69
+ return connections.reduce<AiConnection | null>(
70
+ (best, c) => (!best || c.lastActiveAt.getTime() > best.lastActiveAt.getTime() ? c : best),
71
+ null,
72
+ );
73
+ }
74
+
75
+ /** The configured-state TITLE, naming the connected assistant(s) — FUT req (F). */
76
+ export function connectedTitle(
77
+ connections: readonly AiConnection[],
78
+ hosts: readonly AiHostGuide[],
79
+ connectedHostId: string | undefined,
80
+ ): string {
81
+ const providers = [...new Set(connections.map((c) => c.host).filter((h): h is AiProvider => h !== null))];
82
+ if (providers.length === 1) return `${PROVIDER_LABEL[providers[0]!]} Conectado`;
83
+ if (providers.length > 1) {
84
+ return `${providers.map((p) => PROVIDER_LABEL[p]).join(", ")} conectados`;
85
+ }
86
+ // Legacy connection with no attributed provider — name the completed host.
87
+ if (connections.length > 0) return `${hostLabel(hosts, connectedHostId)} Conectado`;
88
+ return "IA conectada";
89
+ }
90
+
91
+ /** The completed summary line (second line) for the configured-state header. */
92
+ export function connectedSummary(connections: readonly AiConnection[]): string {
93
+ const recent = mostRecent(connections);
94
+ if (!recent) return "Integração configurada";
95
+ return activeAgo(recent.lastActiveAt);
96
+ }
@@ -0,0 +1,290 @@
1
+ "use client";
2
+
3
+ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
4
+ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
5
+ import { useEffect, useState } from "react";
6
+
7
+ import { type GuidedNav } from "@12-apps/onboarding";
8
+ import { Progress } from "@12-apps/ui/data-display/Progress";
9
+ import { Button } from "@12-apps/ui/form/Button";
10
+ import { Box } from "@12-apps/ui/mui/Box";
11
+ import { Stack } from "@12-apps/ui/mui/Stack";
12
+ import { Text } from "@12-apps/ui/typography/Text";
13
+
14
+ import type { AiHostConfigureStage, AiHostGuide } from "../guide";
15
+ import { activeAgo, connectionForHost, hostLabel, type AiConnection } from "./ai-connection-utils";
16
+ import {
17
+ EndpointCopyBlock,
18
+ HostConnectHeader,
19
+ HostDocsLink,
20
+ HostOpenButton,
21
+ HostStepList,
22
+ PromptCopyBlock,
23
+ } from "./host-connect-guide";
24
+
25
+ /** The host's connect steps split across the Configurar / Conectar stages. */
26
+ const CONFIGURE_STEP_COUNT = 3;
27
+
28
+ /** How often the Confirmar step re-checks the live connection while waiting. */
29
+ const POLL_INTERVAL_MS = 3000;
30
+
31
+ /** A step's continue/back controls (Voltar left, primary next right). */
32
+ function StepNav({
33
+ nav,
34
+ onNext,
35
+ nextLabel = "Próximo",
36
+ nextDisabled = false,
37
+ nextTestId,
38
+ }: {
39
+ nav: GuidedNav;
40
+ onNext: () => void;
41
+ nextLabel?: string;
42
+ nextDisabled?: boolean;
43
+ nextTestId?: string;
44
+ }): React.JSX.Element {
45
+ return (
46
+ <Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
47
+ <Button variant="ghost" onClick={() => nav.back()} data-testid="ai-step-back">
48
+ Voltar
49
+ </Button>
50
+ <Button onClick={onNext} disabled={nextDisabled} data-testid={nextTestId}>
51
+ {nextLabel}
52
+ </Button>
53
+ </Stack>
54
+ );
55
+ }
56
+
57
+ /** Step 2 — copy the store URL; copying is the action that advances the wizard. */
58
+ export function CopyUrlStep({ nav, endpointUrl }: { nav: GuidedNav; endpointUrl: string }): React.JSX.Element {
59
+ return (
60
+ <Stack spacing={3} data-testid="ai-copy-step">
61
+ <Box>
62
+ <Text variant="heading" size="sm" as="h2">
63
+ Copie a URL da sua loja
64
+ </Text>
65
+ <Text variant="caption" as="p" color="secondary">
66
+ É o único dado que você cola no assistente — ao copiar, seguimos para o próximo passo.
67
+ </Text>
68
+ </Box>
69
+ <EndpointCopyBlock endpointUrl={endpointUrl} copied={false} onCopy={() => nav.next()} />
70
+ <Box>
71
+ <Button variant="ghost" onClick={() => nav.back()} data-testid="ai-step-back">
72
+ Voltar
73
+ </Button>
74
+ </Box>
75
+ </Stack>
76
+ );
77
+ }
78
+
79
+ /** Step 3 — open the host's connectors and add + configure the custom connector. */
80
+ export function ConfigureStep({ nav, host }: { nav: GuidedNav; host: AiHostGuide }): React.JSX.Element {
81
+ // Hosts with no direct link (e.g. Claude Desktop) have nothing to open, so
82
+ // "Próximo" is available immediately; otherwise it unlocks on the open click.
83
+ const [opened, setOpened] = useState(!host.link);
84
+ return (
85
+ <Stack spacing={2.5} data-testid="ai-configure-step">
86
+ <HostConnectHeader host={host} />
87
+ <HostOpenButton host={host} onOpen={() => setOpened(true)} />
88
+ <HostStepList steps={host.steps.slice(0, CONFIGURE_STEP_COUNT)} start={1} />
89
+ <HostDocsLink host={host} />
90
+ <StepNav nav={nav} onNext={() => nav.next()} nextDisabled={!opened} nextTestId="ai-configure-next" />
91
+ </Stack>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * One stage of a host's per-stage configuration (e.g. ChatGPT's "enable
97
+ * developer mode" then "configurar"). Each stage has its OWN deep link — opening
98
+ * it unlocks "Próximo" — and its own instructions.
99
+ */
100
+ export function ConfigureStageStep({
101
+ nav,
102
+ host,
103
+ stage,
104
+ }: {
105
+ nav: GuidedNav;
106
+ host: AiHostGuide;
107
+ stage: AiHostConfigureStage;
108
+ }): React.JSX.Element {
109
+ const [opened, setOpened] = useState(!stage.link);
110
+ return (
111
+ <Stack spacing={2.5} data-testid={`ai-stage-${stage.id}`}>
112
+ <HostConnectHeader host={host} />
113
+ {stage.link && (
114
+ <Box>
115
+ <Button
116
+ onClick={() => {
117
+ window.open(stage.link!.url, "_blank", "noopener,noreferrer");
118
+ setOpened(true);
119
+ }}
120
+ data-testid={`ai-stage-link-${stage.id}`}
121
+ >
122
+ {stage.link.label}
123
+ <OpenInNewIcon sx={{ fontSize: 16, ml: 0.5 }} />
124
+ </Button>
125
+ </Box>
126
+ )}
127
+ <HostStepList steps={stage.steps} start={1} />
128
+ <HostDocsLink host={host} />
129
+ <StepNav
130
+ nav={nav}
131
+ onNext={() => nav.next()}
132
+ nextDisabled={!opened}
133
+ nextTestId={`ai-stage-next-${stage.id}`}
134
+ />
135
+ </Stack>
136
+ );
137
+ }
138
+
139
+ /** Step 4 — connect: log in, authorize and activate the connector. */
140
+ export function ConnectStep({
141
+ nav,
142
+ host,
143
+ connectPrompt,
144
+ }: {
145
+ nav: GuidedNav;
146
+ host: AiHostGuide;
147
+ connectPrompt: string;
148
+ }): React.JSX.Element {
149
+ return (
150
+ <Stack spacing={2.5} data-testid="ai-connect-step">
151
+ <HostConnectHeader host={host} />
152
+ <HostStepList steps={host.steps.slice(CONFIGURE_STEP_COUNT)} start={CONFIGURE_STEP_COUNT + 1} />
153
+ <PromptCopyBlock
154
+ title="Cole esta mensagem no assistente"
155
+ caption="Assim ele se conecta, se identifica (Claude, ChatGPT…) e registramos a conexão."
156
+ message={connectPrompt}
157
+ />
158
+ <HostDocsLink host={host} />
159
+ <StepNav nav={nav} onNext={() => nav.next()} nextLabel="Continuar" nextTestId="ai-connect-done" />
160
+ </Stack>
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Simplified-flow step (host has a published `pluginUrl`): open the one-click
166
+ * plugin (install + authorize), then paste the prompt asking the assistant to
167
+ * connect. No URL to copy, no manual connector. The next step (Confirmar) waits.
168
+ */
169
+ export function InstallStep({
170
+ nav,
171
+ host,
172
+ connectPrompt,
173
+ }: {
174
+ nav: GuidedNav;
175
+ host: AiHostGuide;
176
+ connectPrompt: string;
177
+ }): React.JSX.Element {
178
+ const [opened, setOpened] = useState(false);
179
+ return (
180
+ <Stack spacing={2.5} data-testid="ai-install-step">
181
+ <HostConnectHeader host={host} />
182
+ <Text variant="body" as="p" color="secondary">
183
+ Abra o plugin da sua loja, clique em Instalar e autorize o acesso — sem copiar URL nem gerar
184
+ credenciais.
185
+ </Text>
186
+ {host.pluginUrl && (
187
+ <Box>
188
+ <Button
189
+ onClick={() => {
190
+ window.open(host.pluginUrl!, "_blank", "noopener,noreferrer");
191
+ setOpened(true);
192
+ }}
193
+ data-testid="ai-install-open"
194
+ >
195
+ Instalar o plugin da loja
196
+ <OpenInNewIcon sx={{ fontSize: 16, ml: 0.5 }} />
197
+ </Button>
198
+ </Box>
199
+ )}
200
+ <PromptCopyBlock
201
+ title="Peça ao assistente para conectar"
202
+ caption="Cole no assistente para ele se conectar, se identificar e confirmar o acesso."
203
+ message={connectPrompt}
204
+ />
205
+ <HostDocsLink host={host} />
206
+ <StepNav
207
+ nav={nav}
208
+ onNext={() => nav.next()}
209
+ nextLabel="Continuar"
210
+ nextDisabled={!opened}
211
+ nextTestId="ai-install-done"
212
+ />
213
+ </Stack>
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Step 5: auto-detect the live connection. While waiting it polls the server
219
+ * (the sign-in / `announceAiConnection` tool call registers the connection
220
+ * server-side), showing a spinner — the owner never clicks "verify". Completes
221
+ * when the selected host's connection appears.
222
+ */
223
+ export function ConfirmStep({
224
+ nav,
225
+ connections,
226
+ hosts,
227
+ onRetest,
228
+ }: {
229
+ nav: GuidedNav;
230
+ connections: readonly AiConnection[];
231
+ hosts: readonly AiHostGuide[];
232
+ onRetest: () => void;
233
+ }): React.JSX.Element {
234
+ const selectedHostId = nav.data.selectedHost as string | undefined;
235
+ const connection = connectionForHost(connections, selectedHostId);
236
+ const label = connection?.clientName ?? hostLabel(hosts, selectedHostId);
237
+
238
+ // Auto-detect: re-check on an interval until the connection shows up. The
239
+ // interval is cleared as soon as `connection` is non-null (and on unmount).
240
+ useEffect(() => {
241
+ if (connection) return undefined;
242
+ const id = setInterval(() => onRetest(), POLL_INTERVAL_MS);
243
+ return () => clearInterval(id);
244
+ }, [connection, onRetest]);
245
+
246
+ if (connection) {
247
+ return (
248
+ <Stack spacing={2} data-testid="ai-confirm-connected">
249
+ <Stack direction="row" spacing={1.5} alignItems="center">
250
+ <CheckCircleIcon sx={{ color: "success.main" }} />
251
+ <Box>
252
+ <Text variant="body" weight="bold" as="p">
253
+ {label} está conectado à sua loja.
254
+ </Text>
255
+ <Text variant="caption" as="p" color="secondary">
256
+ {activeAgo(connection.lastActiveAt)}
257
+ </Text>
258
+ </Box>
259
+ </Stack>
260
+ <Box>
261
+ <Button onClick={() => nav.complete({ connectedHost: selectedHostId })}>Concluir</Button>
262
+ </Box>
263
+ </Stack>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <Stack spacing={2.5} data-testid="ai-confirm-waiting">
269
+ <Stack direction="row" spacing={1.5} alignItems="center">
270
+ <Progress variant="circular" circularSize={22} thickness={4} dataTestId="ai-confirm-spinner" />
271
+ <Box>
272
+ <Text variant="body" weight="bold" as="p">
273
+ Esperando conexão
274
+ </Text>
275
+ <Text variant="caption" as="p" color="secondary">
276
+ Assim que você autorizar o acesso no {label}, ela aparece aqui automaticamente.
277
+ </Text>
278
+ </Box>
279
+ </Stack>
280
+ <Stack direction="row" spacing={1}>
281
+ <Button variant="ghost" size="sm" onClick={onRetest} data-testid="ai-confirm-retest">
282
+ Testar agora
283
+ </Button>
284
+ <Button variant="ghost" size="sm" onClick={() => nav.back()}>
285
+ Voltar
286
+ </Button>
287
+ </Stack>
288
+ </Stack>
289
+ );
290
+ }
@@ -0,0 +1,70 @@
1
+ import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
2
+ import CategoryIcon from "@mui/icons-material/Category";
3
+ import HubIcon from "@mui/icons-material/Hub";
4
+ import Inventory2Icon from "@mui/icons-material/Inventory2";
5
+ import QueryStatsIcon from "@mui/icons-material/QueryStats";
6
+ import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
7
+
8
+ import { Box } from "@12-apps/ui/mui/Box";
9
+
10
+ import type { AiHostBrand } from "../guide";
11
+
12
+ /**
13
+ * Brand accents for the host avatars. These are deliberately hardcoded (not
14
+ * theme tokens): they are BRAND marks whose recognizability depends on the
15
+ * vendor's own accent colour, so the "always semantic colours" rule doesn't
16
+ * apply here. The glyphs are simple, original stand-ins — not the vendors'
17
+ * proprietary logo artwork.
18
+ */
19
+ const BRAND: Record<AiHostBrand, { bg: string; Icon: typeof AutoAwesomeIcon; label: string }> = {
20
+ claude: { bg: "#D97757", Icon: AutoAwesomeIcon, label: "Claude (Anthropic)" },
21
+ openai: { bg: "#10A37F", Icon: HubIcon, label: "OpenAI" },
22
+ };
23
+
24
+ /** A round brand chip: the vendor accent + a simple glyph. */
25
+ export function HostBrandAvatar({
26
+ brand,
27
+ size = 40,
28
+ }: {
29
+ brand: AiHostBrand;
30
+ size?: number;
31
+ }): React.JSX.Element {
32
+ const { bg, Icon, label } = BRAND[brand];
33
+ return (
34
+ <Box
35
+ aria-label={label}
36
+ sx={{
37
+ width: size,
38
+ height: size,
39
+ borderRadius: "50%",
40
+ bgcolor: bg,
41
+ color: "#fff",
42
+ display: "flex",
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ flexShrink: 0,
46
+ }}
47
+ >
48
+ <Icon sx={{ fontSize: size * 0.55 }} />
49
+ </Box>
50
+ );
51
+ }
52
+
53
+ const CAPABILITY_ICON: Record<string, typeof AutoAwesomeIcon> = {
54
+ orders: ReceiptLongIcon,
55
+ inventory: Inventory2Icon,
56
+ catalog: CategoryIcon,
57
+ sales: QueryStatsIcon,
58
+ };
59
+
60
+ /** The icon for a capability card, by capability id (falls back to a sparkle). */
61
+ export function CapabilityIcon({
62
+ id,
63
+ fontSize = 24,
64
+ }: {
65
+ id: string;
66
+ fontSize?: number;
67
+ }): React.JSX.Element {
68
+ const Icon = CAPABILITY_ICON[id] ?? AutoAwesomeIcon;
69
+ return <Icon sx={{ fontSize }} />;
70
+ }