mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
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:
1 parent
a312248d49
commit
aab9faf84a
6 files changed
+94
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
Vendored
+9
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user