From 4efce5f307278811be360172408d8480b7ae574b Mon Sep 17 00:00:00 2001 From: Oleg Date: Sun, 26 Jul 2026 13:34:08 +0000 Subject: [PATCH] Add embeddable chat widget for websites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the existing portal-token/SSE mechanism (customers.portalToken, /api/portal/events) for anonymous widget visitors instead of building a parallel auth system. Public API: session bootstrap + messages endpoint, both scoped by a non-secret siteKey with optional origin allowlist. Embeddable script (public/widget.js) is a small self-contained vanilla JS file: floating button + iframe kept mounted for a live SSE connection, with a postMessage bridge for the unread badge. Admin UI at Settings -> Виджет manages sites and shows the embed snippet. Also generalized recordTelegramInboundMessage -> recordChatInboundMessage (channel param) since Telegram and the widget need the identical find-or-create-open-ticket heuristic. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- public/widget.js | 78 +++ src/app/(admin)/dashboard/ticket-board.tsx | 10 +- src/app/(admin)/nav-links.tsx | 3 +- src/app/(admin)/settings/widget/page.tsx | 23 + .../settings/widget/widget-sites-manager.tsx | 129 ++++ src/app/api/widget/messages/route.ts | 71 ++ src/app/api/widget/session/route.ts | 52 ++ src/app/api/widget/sites/route.ts | 51 ++ src/app/widget/chat/page.tsx | 10 + src/app/widget/chat/widget-chat.tsx | 143 ++++ src/app/widget/layout.tsx | 3 + .../db/migrations/0002_broad_stellaris.sql | 10 + src/lib/db/migrations/meta/0002_snapshot.json | 609 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 15 +- src/lib/events/use-ticket-events.ts | 3 +- src/lib/telegram/bot.ts | 5 +- src/lib/tickets/service.ts | 57 +- src/lib/tickets/types.ts | 2 +- 19 files changed, 1267 insertions(+), 14 deletions(-) create mode 100644 public/widget.js create mode 100644 src/app/(admin)/settings/widget/page.tsx create mode 100644 src/app/(admin)/settings/widget/widget-sites-manager.tsx create mode 100644 src/app/api/widget/messages/route.ts create mode 100644 src/app/api/widget/session/route.ts create mode 100644 src/app/api/widget/sites/route.ts create mode 100644 src/app/widget/chat/page.tsx create mode 100644 src/app/widget/chat/widget-chat.tsx create mode 100644 src/app/widget/layout.tsx create mode 100644 src/lib/db/migrations/0002_broad_stellaris.sql create mode 100644 src/lib/db/migrations/meta/0002_snapshot.json diff --git a/public/widget.js b/public/widget.js new file mode 100644 index 0000000..322f4fc --- /dev/null +++ b/public/widget.js @@ -0,0 +1,78 @@ +(function () { + var scriptEl = document.currentScript; + if (!scriptEl) return; + + var siteKey = scriptEl.getAttribute("data-key"); + if (!siteKey) { + console.error("[top-tickets widget] missing data-key attribute"); + return; + } + + var origin = new URL(scriptEl.src).origin; + var open = false; + + var button = document.createElement("button"); + button.setAttribute("aria-label", "Открыть чат поддержки"); + button.style.cssText = [ + "position:fixed", "right:20px", "bottom:20px", "z-index:2147483000", + "width:56px", "height:56px", "border-radius:999px", "border:none", + "background:#7c3aed", "color:#fff", "cursor:pointer", + "box-shadow:0 6px 20px rgba(0,0,0,.25)", + "display:flex", "align-items:center", "justify-content:center", + "font-family:system-ui,sans-serif", + ].join(";"); + button.innerHTML = + ''; + + var badge = document.createElement("span"); + badge.style.cssText = [ + "position:absolute", "top:-2px", "right:-2px", "width:12px", "height:12px", + "border-radius:999px", "background:#dc2626", "border:2px solid #fff", + "display:none", + ].join(";"); + button.style.position = "fixed"; + button.appendChild(badge); + + var panel = document.createElement("div"); + var isMobile = window.innerWidth < 480; + panel.style.cssText = [ + "position:fixed", + isMobile ? "right:0" : "right:20px", + isMobile ? "bottom:0" : "bottom:88px", + isMobile ? "width:100vw" : "width:360px", + isMobile ? "height:100vh" : "height:520px", + "max-height:80vh", + "border-radius:" + (isMobile ? "0" : "16px"), + "overflow:hidden", + "box-shadow:0 12px 40px rgba(0,0,0,.3)", + "z-index:2147483000", + "display:none", + ].join(";"); + + var iframe = document.createElement("iframe"); + iframe.src = origin + "/widget/chat?key=" + encodeURIComponent(siteKey); + iframe.style.cssText = "width:100%;height:100%;border:none;"; + panel.appendChild(iframe); + + function setOpen(next) { + open = next; + panel.style.display = open ? "block" : "none"; + if (open) { + badge.style.display = "none"; + } + } + + button.addEventListener("click", function () { + setOpen(!open); + }); + + window.addEventListener("message", function (event) { + if (event.origin !== origin) return; + if (event.data && event.data.type === "top-tickets:new-message" && !open) { + badge.style.display = "block"; + } + }); + + document.body.appendChild(panel); + document.body.appendChild(button); +})(); diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index 8b1c1a8..1273bc1 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; -import { Send, Globe, UserRound, Mail, Plus } from "lucide-react"; +import { Send, Globe, UserRound, Mail, MessageSquare, Plus } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; import type { TicketDTO, TicketStatus } from "@/lib/tickets/types"; @@ -16,7 +16,13 @@ const COLUMNS: { status: TicketStatus; label: string }[] = [ { status: "closed", label: "Закрытые" }, ]; -const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound, email: Mail } as const; +const CHANNEL_ICON = { + telegram: Send, + portal: Globe, + manual: UserRound, + email: Mail, + widget: MessageSquare, +} as const; const PRIORITY_COLOR: Record = { low: "var(--text-faint)", diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index acf4ce4..74cd2c0 100644 --- a/src/app/(admin)/nav-links.tsx +++ b/src/app/(admin)/nav-links.tsx @@ -2,12 +2,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, Send, Mail } from "lucide-react"; +import { LayoutDashboard, Send, Mail, Code2 } from "lucide-react"; const links = [ { href: "/dashboard", label: "Дашборд", icon: LayoutDashboard }, { href: "/settings/telegram", label: "Telegram", icon: Send }, { href: "/settings/email", label: "Почта", icon: Mail }, + { href: "/settings/widget", label: "Виджет", icon: Code2 }, ]; export function NavLinks() { diff --git a/src/app/(admin)/settings/widget/page.tsx b/src/app/(admin)/settings/widget/page.tsx new file mode 100644 index 0000000..171974e --- /dev/null +++ b/src/app/(admin)/settings/widget/page.tsx @@ -0,0 +1,23 @@ +import { listWidgetSites } from "@/lib/tickets/service"; +import { WidgetSitesManager } from "./widget-sites-manager"; + +export default async function WidgetSettingsPage() { + const sites = await listWidgetSites(); + return ( +
+

Виджет для сайтов

+

+ Встройте чат на любой сайт — сообщения посетителей превращаются в заявки. +

+ ({ + id: s.id, + name: s.name, + siteKey: s.siteKey, + allowedOrigin: s.allowedOrigin, + enabled: s.enabled, + }))} + /> +
+ ); +} diff --git a/src/app/(admin)/settings/widget/widget-sites-manager.tsx b/src/app/(admin)/settings/widget/widget-sites-manager.tsx new file mode 100644 index 0000000..bf07486 --- /dev/null +++ b/src/app/(admin)/settings/widget/widget-sites-manager.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Copy, Check } from "lucide-react"; + +interface Site { + id: string; + name: string; + siteKey: string; + allowedOrigin: string | null; + enabled: boolean; +} + +export function WidgetSitesManager({ initialSites }: { initialSites: Site[] }) { + const [sites, setSites] = useState(initialSites); + const [name, setName] = useState(""); + const [allowedOrigin, setAllowedOrigin] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch("/api/widget/sites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, allowedOrigin: allowedOrigin || undefined }), + }); + + setLoading(false); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось создать сайт"); + return; + } + + const { site } = await res.json(); + setSites((prev) => [site, ...prev]); + setName(""); + setAllowedOrigin(""); + } + + async function toggleEnabled(site: Site) { + const res = await fetch("/api/widget/sites", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: site.id, enabled: !site.enabled }), + }); + if (res.ok) { + const { site: updated } = await res.json(); + setSites((prev) => prev.map((s) => (s.id === site.id ? updated : s))); + } + } + + return ( +
+
+ + + +
+ + {error &&

{error}

} + + {sites.length === 0 &&

Сайтов пока нет.

} + +
+ {sites.map((site) => ( + toggleEnabled(site)} /> + ))} +
+
+ ); +} + +function SiteCard({ site, onToggle }: { site: Site; onToggle: () => void }) { + const [copied, setCopied] = useState(false); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const snippet = ``; + + async function copySnippet() { + await navigator.clipboard.writeText(snippet); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+
+
+

{site.name}

+ {site.allowedOrigin &&

{site.allowedOrigin}

} +
+ +
+
+ {snippet} + +
+
+ ); +} diff --git a/src/app/api/widget/messages/route.ts b/src/app/api/widget/messages/route.ts new file mode 100644 index 0000000..b089052 --- /dev/null +++ b/src/app/api/widget/messages/route.ts @@ -0,0 +1,71 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + getWidgetSiteByKey, + getCustomerByPortalToken, + renameCustomerIfGuest, + recordChatInboundMessage, +} from "@/lib/tickets/service"; + +// Simple in-memory per-visitor rate limit — this is a public, unauthenticated +// endpoint. Good enough at MVP scale; resets on restart. +const attempts = new Map(); +const MAX_ATTEMPTS = 20; +const WINDOW_MS = 5 * 60 * 1000; + +function isRateLimited(key: string): boolean { + const now = Date.now(); + const entry = attempts.get(key); + if (!entry || entry.resetAt < now) { + attempts.set(key, { count: 1, resetAt: now + WINDOW_MS }); + return false; + } + entry.count += 1; + return entry.count > MAX_ATTEMPTS; +} + +const schema = z.object({ + siteKey: z.string().min(1), + token: z.string().min(1), + body: z.string().min(1).max(4_000), + name: z.string().max(100).optional(), +}); + +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + if (isRateLimited(parsed.data.token)) { + return NextResponse.json({ error: "Слишком много сообщений — подождите немного" }, { status: 429 }); + } + + const site = await getWidgetSiteByKey(parsed.data.siteKey); + if (!site || !site.enabled) { + return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 }); + } + + const customer = await getCustomerByPortalToken(parsed.data.token); + if (!customer) { + return NextResponse.json({ error: "Invalid token" }, { status: 404 }); + } + + const name = parsed.data.name?.trim(); + if (name) { + await renameCustomerIfGuest(customer.id, name); + } + + const result = await recordChatInboundMessage({ + customerId: customer.id, + channel: "widget", + body: parsed.data.body, + authorName: name || customer.displayName, + subjectForNewTicket: `Чат (${site.name}): ${parsed.data.body.slice(0, 60)}`, + }); + + return NextResponse.json(result, { status: 201 }); +} diff --git a/src/app/api/widget/session/route.ts b/src/app/api/widget/session/route.ts new file mode 100644 index 0000000..137383f --- /dev/null +++ b/src/app/api/widget/session/route.ts @@ -0,0 +1,52 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + getWidgetSiteByKey, + getCustomerByPortalToken, + createGuestCustomer, + listTicketsForCustomer, + getTicketForCustomer, +} from "@/lib/tickets/service"; + +const schema = z.object({ + siteKey: z.string().min(1), + token: z.string().optional(), +}); + +function originAllowed(request: Request, allowedOrigin: string | null): boolean { + if (!allowedOrigin) return true; + return request.headers.get("origin") === allowedOrigin; +} + +/** Bootstraps (or resumes) a widget visitor's session and their current conversation, in one call. */ +export async function POST(request: Request) { + const body = await request.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + const site = await getWidgetSiteByKey(parsed.data.siteKey); + if (!site || !site.enabled) { + return NextResponse.json({ error: "Unknown or disabled widget" }, { status: 404 }); + } + if (!originAllowed(request, site.allowedOrigin)) { + return NextResponse.json({ error: "Origin not allowed" }, { status: 403 }); + } + + const customer = + (parsed.data.token ? await getCustomerByPortalToken(parsed.data.token) : null) ?? + (await createGuestCustomer()); + + const tickets = await listTicketsForCustomer(customer.id); + const openTicket = tickets.find((t) => t.status !== "closed") ?? null; + const result = openTicket ? await getTicketForCustomer(customer.id, openTicket.id) : null; + + return NextResponse.json({ + token: customer.portalToken, + ticket: result?.ticket ?? null, + messages: result?.messages ?? [], + }); +} diff --git a/src/app/api/widget/sites/route.ts b/src/app/api/widget/sites/route.ts new file mode 100644 index 0000000..affc173 --- /dev/null +++ b/src/app/api/widget/sites/route.ts @@ -0,0 +1,51 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireSession } from "@/lib/auth/require"; +import { listWidgetSites, createWidgetSite, setWidgetSiteEnabled } from "@/lib/tickets/service"; + +export async function GET() { + const { session, response } = await requireSession(); + if (!session) return response; + + return NextResponse.json({ sites: await listWidgetSites() }); +} + +const createSchema = z.object({ + name: z.string().min(1).max(100), + allowedOrigin: z.string().url().optional(), +}); + +export async function POST(request: Request) { + const { session, response } = await requireSession(); + if (!session) return response; + + const body = await request.json().catch(() => null); + const parsed = createSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + const site = await createWidgetSite(parsed.data.name, parsed.data.allowedOrigin); + return NextResponse.json({ site }, { status: 201 }); +} + +const patchSchema = z.object({ + id: z.string().min(1), + enabled: z.boolean(), +}); + +export async function PATCH(request: Request) { + const { session, response } = await requireSession(); + if (!session) return response; + + const body = await request.json().catch(() => null); + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + const site = await setWidgetSiteEnabled(parsed.data.id, parsed.data.enabled); + return NextResponse.json({ site }); +} diff --git a/src/app/widget/chat/page.tsx b/src/app/widget/chat/page.tsx new file mode 100644 index 0000000..86a58d6 --- /dev/null +++ b/src/app/widget/chat/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { WidgetChat } from "./widget-chat"; + +export default function WidgetChatPage() { + return ( + + + + ); +} diff --git a/src/app/widget/chat/widget-chat.tsx b/src/app/widget/chat/widget-chat.tsx new file mode 100644 index 0000000..71d10c1 --- /dev/null +++ b/src/app/widget/chat/widget-chat.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AnimatePresence, motion } from "framer-motion"; +import { Send } from "lucide-react"; +import { useTicketEvents } from "@/lib/events/use-ticket-events"; +import { formatRelativeTime } from "@/lib/format"; +import type { MessageDTO } from "@/lib/tickets/types"; + +const STORAGE_KEY = "top-tickets-widget-token"; + +export function WidgetChat() { + const siteKey = useSearchParams().get("key") ?? ""; + + const [token, setToken] = useState(null); + const [messages, setMessages] = useState([]); + const [name, setName] = useState(""); + const [draft, setDraft] = useState(""); + const [ready, setReady] = useState(false); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) ?? undefined; + fetch("/api/widget/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siteKey, token: stored }), + }) + .then((res) => res.json()) + .then((data) => { + localStorage.setItem(STORAGE_KEY, data.token); + setToken(data.token); + setMessages(data.messages ?? []); + setReady(true); + }); + }, [siteKey]); + + useTicketEvents(token ? `/api/portal/events?token=${encodeURIComponent(token)}` : "", (event) => { + if (event.type !== "message.created") return; + setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message])); + if (event.message.authorType === "agent") { + window.parent.postMessage({ type: "top-tickets:new-message" }, "*"); + } + }); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + async function sendMessage() { + if (!draft.trim() || !token) return; + setSending(true); + const res = await fetch("/api/widget/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siteKey, token, body: draft, name: name.trim() || undefined }), + }); + setSending(false); + if (res.ok) { + setDraft(""); + } + } + + if (!ready) { + return
; + } + + const isFirstMessage = messages.length === 0; + + return ( +
+ {isFirstMessage && ( + + )} + +
+ + {messages.map((message) => ( + + ))} + + {isFirstMessage && ( +

Напишите нам — обычно отвечаем быстро.

+ )} +
+
+ +
+