feat(integrations): embed-сниппет для Web-виджета

После создания WEB-интеграции «код вставки на сайт» негде было посмотреть —
функционал не был реализован (вариант A, без миграций и backend-изменений).

- model.ts: webWidgetSnippet(channelCode) собирает сниппет
  <script src="<VITE_PUBLIC_HUB_URL>/chat-widget.js" data-channel="<code>" async>
  из публичного домена Hub и кода канала (лоадер уже работает по data-channel).
- vite-env.d.ts: типизация VITE_PUBLIC_HUB_URL (+ VITE_API_BASE_URL).
- .env.example: VITE_PUBLIC_HUB_URL с примером (SPEC-HUB-0003 §3).
- IntegrationForm.tsx: для WEB + выбранного канала — поле «Код вставки на сайт»
  (mono, readonly) + кнопка «Копировать» (clipboard, состояние «Скопировано»).
- styles.css: блок .integration-snippet.
- model.test.ts: сборка сниппета, обрезка trailing slash, fallback на относительный URL.

Расхождение со спекой §3: используем data-channel вместо data-widget-key
(public-widget-key в коде не существует). Полное соответствие §3 — вариант B
(отдельная задача: поле widget_key в модели + валидация ключа/origin).
This commit is contained in:
Andrey committed 2026-07-09 20:30:42 +03:00
1 parent a312248d49
commit aab9faf84a
6 files changed
+94 -1

No files matched your search

+4
View File
@@ -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 сниппета: <VITE_PUBLIC_HUB_URL>/chat-widget.js
VITE_PUBLIC_HUB_URL=https://hub.edevs.tech
@@ -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<ChannelOption[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 && (
<div className="integration-snippet">
<FormField label="Код вставки на сайт" mono value={widgetSnippet} />
<Button variant="secondary" icon={copied ? "check" : "copy"} iconSize={15} onClick={() => void copySnippet()}>
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
)}
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
@@ -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(
`<script src="https://hub.edevs.tech/chat-widget.js" data-channel="edeves" async></script>`,
);
});
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(
`<script src="https://hub.edevs.tech/chat-widget.js" data-channel="foxray" async></script>`,
);
});
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(
`<script src="/chat-widget.js" data-channel="edeves" async></script>`,
);
});
});
@@ -49,3 +49,11 @@ export const KIND_LABEL: Record<IntegrationKind, string> = {
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 сниппета: <hub>/chat-widget.js?data-channel=<code>.
const PUBLIC_HUB_URL = (import.meta.env.VITE_PUBLIC_HUB_URL ?? "").replace(/\/+$/, "");
export function webWidgetSnippet(channelCode: string): string {
return `<script src="${PUBLIC_HUB_URL}/chat-widget.js" data-channel="${channelCode}" async></script>`;
}
@@ -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;
}
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_PUBLIC_HUB_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}