diff --git a/.env.example b/.env.example index ca83ed8..a06e0a3 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,7 @@ WEB_CHAT_PORT=5175 BACKEND_PORT=8010 VITE_API_BASE_URL=http://localhost:8010 + +# Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3). +# Подставляется в src сниппета: /chat-widget.js +VITE_PUBLIC_HUB_URL=https://hub.edevs.tech diff --git a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx index d3e2721..ecb4b50 100644 --- a/apps/internal-ui/src/features/integrations/IntegrationForm.tsx +++ b/apps/internal-ui/src/features/integrations/IntegrationForm.tsx @@ -2,9 +2,10 @@ import { Modal } from "antd"; import { useEffect, useState } from "react"; import { api } from "../../api/client"; +import { Icon } from "../../shared/icons"; import { FormField, SelectField } from "../../shared/form-controls"; import { Button } from "../../shared/ui-controls"; -import { fetchChannels, PROVIDERS, type ChannelOption, type Integration, type IntegrationProvider } from "./model"; +import { fetchChannels, PROVIDERS, webWidgetSnippet, type ChannelOption, type Integration, type IntegrationProvider } from "./model"; const PROVIDER_OPTIONS: Array<[string, string]> = (Object.keys(PROVIDERS) as IntegrationProvider[]).map( (key) => [key, PROVIDERS[key].label], @@ -21,9 +22,23 @@ export function IntegrationForm({ initial, onClose, onSaved }: { initial: Integr const [channels, setChannels] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); const meta = PROVIDERS[provider]; const isMessenger = meta.kind === "MESSENGER"; + const isWeb = provider === "WEB"; + const widgetChannel = channels.find((item) => String(item.id) === channelId) ?? null; + const widgetSnippet = isWeb && widgetChannel ? webWidgetSnippet(widgetChannel.code) : ""; + + async function copySnippet() { + try { + await navigator.clipboard.writeText(widgetSnippet); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + } useEffect(() => { if (isMessenger) fetchChannels().then(setChannels).catch(() => setChannels([])); @@ -89,6 +104,14 @@ export function IntegrationForm({ initial, onClose, onSaved }: { initial: Integr options={[["", "— не привязан —"], ...channels.map((c) => [String(c.id), c.name] as [string, string])]} /> )} + {widgetSnippet && ( +
+ + +
+ )} {error &&
{error}
}
diff --git a/apps/internal-ui/src/features/integrations/model.test.ts b/apps/internal-ui/src/features/integrations/model.test.ts new file mode 100644 index 0000000..c0ed7b4 --- /dev/null +++ b/apps/internal-ui/src/features/integrations/model.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("webWidgetSnippet", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("builds the embed snippet from the public hub url and channel code", async () => { + vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech"); + const { webWidgetSnippet } = await import("./model"); + + expect(webWidgetSnippet("edeves")).toBe( + ``, + ); + }); + + it("strips trailing slashes from the hub url", async () => { + vi.stubEnv("VITE_PUBLIC_HUB_URL", "https://hub.edevs.tech/"); + const { webWidgetSnippet } = await import("./model"); + + expect(webWidgetSnippet("foxray")).toBe( + ``, + ); + }); + + it("falls back to a relative src when the hub url is empty", async () => { + vi.stubEnv("VITE_PUBLIC_HUB_URL", ""); + const { webWidgetSnippet } = await import("./model"); + + expect(webWidgetSnippet("edeves")).toBe( + ``, + ); + }); +}); diff --git a/apps/internal-ui/src/features/integrations/model.ts b/apps/internal-ui/src/features/integrations/model.ts index db4115c..94c2739 100644 --- a/apps/internal-ui/src/features/integrations/model.ts +++ b/apps/internal-ui/src/features/integrations/model.ts @@ -49,3 +49,11 @@ export const KIND_LABEL: Record = { export type ChannelOption = { id: number; code: string; name: string }; export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/channels/").then((r) => r.items); + +// Публичный домен Hub для встраивания Web-виджета (SPEC-HUB-0003 §3). +// Подставляется в src сниппета: /chat-widget.js?data-channel=. +const PUBLIC_HUB_URL = (import.meta.env.VITE_PUBLIC_HUB_URL ?? "").replace(/\/+$/, ""); + +export function webWidgetSnippet(channelCode: string): string { + return ``; +} diff --git a/apps/internal-ui/src/features/integrations/styles.css b/apps/internal-ui/src/features/integrations/styles.css index 3d414a4..9bf6393 100644 --- a/apps/internal-ui/src/features/integrations/styles.css +++ b/apps/internal-ui/src/features/integrations/styles.css @@ -73,3 +73,18 @@ gap: 10px; margin-top: 4px; } + +.integration-snippet { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; +} + +.integration-snippet .readonly-field { + width: 100%; +} + +.integration-snippet .form-field input.mono { + font-size: 12px; +} diff --git a/apps/internal-ui/src/vite-env.d.ts b/apps/internal-ui/src/vite-env.d.ts index 11f02fe..bee56a1 100644 --- a/apps/internal-ui/src/vite-env.d.ts +++ b/apps/internal-ui/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_PUBLIC_HUB_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +}