Add embeddable chat widget for websites
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
59e6c32d79
commit
4efce5f307
19 files changed
+1267
-14
No files matched your search
@@ -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 =
|
||||||
|
'<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
|
||||||
|
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||||
import { formatRelativeTime } from "@/lib/format";
|
import { formatRelativeTime } from "@/lib/format";
|
||||||
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
import type { TicketDTO, TicketStatus } from "@/lib/tickets/types";
|
||||||
@@ -16,7 +16,13 @@ const COLUMNS: { status: TicketStatus; label: string }[] = [
|
|||||||
{ status: "closed", label: "Закрытые" },
|
{ 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<TicketDTO["priority"], string> = {
|
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
||||||
low: "var(--text-faint)",
|
low: "var(--text-faint)",
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { LayoutDashboard, Send, Mail } from "lucide-react";
|
import { LayoutDashboard, Send, Mail, Code2 } from "lucide-react";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
||||||
{ href: "/settings/telegram", label: "Telegram", icon: Send },
|
{ href: "/settings/telegram", label: "Telegram", icon: Send },
|
||||||
{ href: "/settings/email", label: "Почта", icon: Mail },
|
{ href: "/settings/email", label: "Почта", icon: Mail },
|
||||||
|
{ href: "/settings/widget", label: "Виджет", icon: Code2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NavLinks() {
|
export function NavLinks() {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="mb-1 text-xl font-bold tracking-tight">Виджет для сайтов</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">
|
||||||
|
Встройте чат на любой сайт — сообщения посетителей превращаются в заявки.
|
||||||
|
</p>
|
||||||
|
<WidgetSitesManager
|
||||||
|
initialSites={sites.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
siteKey: s.siteKey,
|
||||||
|
allowedOrigin: s.allowedOrigin,
|
||||||
|
enabled: s.enabled,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4 sm:flex-row sm:items-end">
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Название сайта</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="top-sysops.ru"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Домен (необязательно)</span>
|
||||||
|
<input
|
||||||
|
value={allowedOrigin}
|
||||||
|
onChange={(e) => setAllowedOrigin(e.target.value)}
|
||||||
|
placeholder="https://top-sysops.ru"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary shrink-0">
|
||||||
|
<Plus size={15} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
{sites.length === 0 && <p className="text-sm text-text-muted">Сайтов пока нет.</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<SiteCard key={site.id} site={site} onToggle={() => toggleEnabled(site)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SiteCard({ site, onToggle }: { site: Site; onToggle: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const snippet = `<script src="${origin}/widget.js" data-key="${site.siteKey}" async></script>`;
|
||||||
|
|
||||||
|
async function copySnippet() {
|
||||||
|
await navigator.clipboard.writeText(snippet);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{site.name}</p>
|
||||||
|
{site.allowedOrigin && <p className="text-xs text-text-faint">{site.allowedOrigin}</p>}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
<input type="checkbox" checked={site.enabled} onChange={onToggle} />
|
||||||
|
{site.enabled ? "Включён" : "Выключен"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-surface-hover px-3 py-2">
|
||||||
|
<code className="flex-1 overflow-x-auto whitespace-nowrap text-xs">{snippet}</code>
|
||||||
|
<button onClick={copySnippet} className="btn btn-ghost shrink-0 px-2 py-1">
|
||||||
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, { count: number; resetAt: number }>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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 ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { WidgetChat } from "./widget-chat";
|
||||||
|
|
||||||
|
export default function WidgetChatPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<WidgetChat />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<MessageDTO[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(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 <div style={{ height: "100%" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirstMessage = messages.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-3">
|
||||||
|
{isFirstMessage && (
|
||||||
|
<label className="mb-2 block text-xs">
|
||||||
|
<span className="mb-1 block text-text-muted">Ваше имя (необязательно)</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Как к вам обращаться?"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{isFirstMessage && (
|
||||||
|
<p className="mt-6 text-center text-sm text-text-muted">Напишите нам — обычно отвечаем быстро.</p>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<textarea
|
||||||
|
rows={1}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Напишите сообщение…"
|
||||||
|
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button onClick={sendMessage} disabled={sending || !draft.trim()} className="btn btn-primary">
|
||||||
|
<Send size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: MessageDTO }) {
|
||||||
|
const isMine = message.authorType === "customer";
|
||||||
|
|
||||||
|
if (message.authorType === "system") {
|
||||||
|
return <p className="my-2 text-center text-xs text-text-faint">{message.body}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
||||||
|
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
||||||
|
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
|
||||||
|
{formatRelativeTime(message.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function WidgetLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div style={{ height: "100vh", width: "100%" }}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE `widget_sites` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`site_key` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`allowed_origin` text,
|
||||||
|
`enabled` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `widget_sites_site_key_unique` ON `widget_sites` (`site_key`);
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "59682ff3-ebbe-44b0-9eab-267734ad7e7c",
|
||||||
|
"prevId": "5e7b8e52-00cf-4515-91e1-c6afdc990797",
|
||||||
|
"tables": {
|
||||||
|
"customers": {
|
||||||
|
"name": "customers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"telegram_chat_id": {
|
||||||
|
"name": "telegram_chat_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"portal_token": {
|
||||||
|
"name": "portal_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"customers_telegram_chat_id_unique": {
|
||||||
|
"name": "customers_telegram_chat_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"telegram_chat_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"customers_portal_token_unique": {
|
||||||
|
"name": "customers_portal_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"portal_token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"mailbox_config": {
|
||||||
|
"name": "mailbox_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_host": {
|
||||||
|
"name": "imap_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_port": {
|
||||||
|
"name": "imap_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 993
|
||||||
|
},
|
||||||
|
"smtp_host": {
|
||||||
|
"name": "smtp_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"smtp_port": {
|
||||||
|
"name": "smtp_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 587
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_enc": {
|
||||||
|
"name": "password_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allow_insecure_tls": {
|
||||||
|
"name": "allow_insecure_tls",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"name": "messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ticket_id": {
|
||||||
|
"name": "ticket_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_type": {
|
||||||
|
"name": "author_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_id": {
|
||||||
|
"name": "author_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_message_id": {
|
||||||
|
"name": "email_message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"messages_ticket_id_tickets_id_fk": {
|
||||||
|
"name": "messages_ticket_id_tickets_id_fk",
|
||||||
|
"tableFrom": "messages",
|
||||||
|
"tableTo": "tickets",
|
||||||
|
"columnsFrom": [
|
||||||
|
"ticket_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"telegram_config": {
|
||||||
|
"name": "telegram_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_token_enc": {
|
||||||
|
"name": "bot_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_username": {
|
||||||
|
"name": "bot_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"name": "tickets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'new'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'normal'"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"name": "channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"assignee_id": {
|
||||||
|
"name": "assignee_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_message_at": {
|
||||||
|
"name": "last_message_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tickets_customer_id_customers_id_fk": {
|
||||||
|
"name": "tickets_customer_id_customers_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "customers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"customer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"tickets_assignee_id_users_id_fk": {
|
||||||
|
"name": "tickets_assignee_id_users_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"assignee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'agent'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"widget_sites": {
|
||||||
|
"name": "widget_sites",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"site_key": {
|
||||||
|
"name": "site_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_origin": {
|
||||||
|
"name": "allowed_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"widget_sites_site_key_unique": {
|
||||||
|
"name": "widget_sites_site_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"site_key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1785070407727,
|
"when": 1785070407727,
|
||||||
"tag": "0001_mushy_purple_man",
|
"tag": "0001_mushy_purple_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785072485453,
|
||||||
|
"tag": "0002_broad_stellaris",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+14
-1
@@ -63,7 +63,7 @@ export const tickets = sqliteTable("tickets", {
|
|||||||
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
priority: text("priority", { enum: ["low", "normal", "high", "urgent"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("normal"),
|
.default("normal"),
|
||||||
channel: text("channel", { enum: ["telegram", "portal", "manual", "email"] }).notNull(),
|
channel: text("channel", { enum: ["telegram", "portal", "manual", "email", "widget"] }).notNull(),
|
||||||
customerId: text("customer_id")
|
customerId: text("customer_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => customers.id, { onDelete: "cascade" }),
|
.references(() => customers.id, { onDelete: "cascade" }),
|
||||||
@@ -120,3 +120,16 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
|
|||||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A site the chat widget is embedded on. Public, non-secret identifier — labels ticket origin, not a trust boundary. */
|
||||||
|
export const widgetSites = sqliteTable("widget_sites", {
|
||||||
|
id: id(),
|
||||||
|
siteKey: text("site_key")
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.$defaultFn(() => crypto.randomBytes(12).toString("base64url")),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
allowedOrigin: text("allowed_origin"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
import type { TicketEvent } from "@/lib/events/bus";
|
import type { TicketEvent } from "@/lib/events/bus";
|
||||||
|
|
||||||
/** Subscribes to a ticket-events SSE endpoint for the lifetime of the component. */
|
/** Subscribes to a ticket-events SSE endpoint for the lifetime of the component. Pass "" to defer subscribing (e.g. until a token is available). */
|
||||||
export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => void) {
|
export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => void) {
|
||||||
const handlerRef = useRef(onEvent);
|
const handlerRef = useRef(onEvent);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -11,6 +11,7 @@ export function useTicketEvents(url: string, onEvent: (event: TicketEvent) => vo
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!url) return;
|
||||||
const source = new EventSource(url);
|
const source = new EventSource(url);
|
||||||
source.onmessage = (e) => {
|
source.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { telegramConfig } from "@/lib/db/schema";
|
|||||||
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||||
import {
|
import {
|
||||||
findOrCreateCustomerByTelegramChatId,
|
findOrCreateCustomerByTelegramChatId,
|
||||||
recordTelegramInboundMessage,
|
recordChatInboundMessage,
|
||||||
} from "@/lib/tickets/service";
|
} from "@/lib/tickets/service";
|
||||||
|
|
||||||
interface BotState {
|
interface BotState {
|
||||||
@@ -36,8 +36,9 @@ function attachHandlers(bot: Telegraf) {
|
|||||||
const text = ctx.message.text;
|
const text = ctx.message.text;
|
||||||
|
|
||||||
const customer = await findOrCreateCustomerByTelegramChatId(chatId, displayName);
|
const customer = await findOrCreateCustomerByTelegramChatId(chatId, displayName);
|
||||||
await recordTelegramInboundMessage({
|
await recordChatInboundMessage({
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
|
channel: "telegram",
|
||||||
body: text,
|
body: text,
|
||||||
authorName: displayName,
|
authorName: displayName,
|
||||||
subjectForNewTicket: text.slice(0, 80),
|
subjectForNewTicket: text.slice(0, 80),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, and, ne, desc } from "drizzle-orm";
|
import { eq, and, ne, desc } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { customers, tickets, messages, users } from "@/lib/db/schema";
|
import { customers, tickets, messages, users, widgetSites } from "@/lib/db/schema";
|
||||||
import { publishTicketEvent } from "@/lib/events/bus";
|
import { publishTicketEvent } from "@/lib/events/bus";
|
||||||
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
|
import type { TicketDTO, MessageDTO, TicketChannel } from "@/lib/tickets/types";
|
||||||
|
|
||||||
@@ -139,12 +139,14 @@ async function findOpenTicketForCustomer(customerId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram has no explicit ticket context — messages are a running chat
|
* Telegram and the website widget have no explicit ticket context —
|
||||||
* stream. Reuse the customer's currently open ticket if one exists,
|
* messages are a running chat stream. Reuse the customer's currently open
|
||||||
* otherwise open a new one. A closed ticket is reopened by a new message.
|
* ticket if one exists, otherwise open a new one. A closed ticket is
|
||||||
|
* reopened by a new message.
|
||||||
*/
|
*/
|
||||||
export async function recordTelegramInboundMessage(params: {
|
export async function recordChatInboundMessage(params: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
|
channel: TicketChannel;
|
||||||
body: string;
|
body: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
subjectForNewTicket: string;
|
subjectForNewTicket: string;
|
||||||
@@ -157,7 +159,7 @@ export async function recordTelegramInboundMessage(params: {
|
|||||||
.insert(tickets)
|
.insert(tickets)
|
||||||
.values({
|
.values({
|
||||||
subject: params.subjectForNewTicket,
|
subject: params.subjectForNewTicket,
|
||||||
channel: "telegram" satisfies TicketChannel,
|
channel: params.channel,
|
||||||
customerId: params.customerId,
|
customerId: params.customerId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -371,6 +373,49 @@ export async function getTicketForCustomer(customerId: string, ticketId: string)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createWidgetSite(name: string, allowedOrigin?: string) {
|
||||||
|
const [site] = await db
|
||||||
|
.insert(widgetSites)
|
||||||
|
.values({ name, allowedOrigin: allowedOrigin || null })
|
||||||
|
.returning();
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWidgetSites() {
|
||||||
|
return db.query.widgetSites.findMany({ orderBy: desc(widgetSites.createdAt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWidgetSiteByKey(siteKey: string) {
|
||||||
|
return db.query.widgetSites.findFirst({ where: eq(widgetSites.siteKey, siteKey) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWidgetSiteEnabled(id: string, enabled: boolean) {
|
||||||
|
const [site] = await db
|
||||||
|
.update(widgetSites)
|
||||||
|
.set({ enabled })
|
||||||
|
.where(eq(widgetSites.id, id))
|
||||||
|
.returning();
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUEST_NAME = "Гость";
|
||||||
|
|
||||||
|
/** Only overwrites the placeholder name — never clobbers a name the visitor already gave. */
|
||||||
|
export async function renameCustomerIfGuest(customerId: string, name: string) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(customers)
|
||||||
|
.set({ displayName: trimmed })
|
||||||
|
.where(and(eq(customers.id, customerId), eq(customers.displayName, GUEST_NAME)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGuestCustomer() {
|
||||||
|
const [customer] = await db.insert(customers).values({ displayName: GUEST_NAME }).returning();
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createManualTicket(params: {
|
export async function createManualTicket(params: {
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type TicketStatus = "new" | "open" | "pending" | "closed";
|
export type TicketStatus = "new" | "open" | "pending" | "closed";
|
||||||
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
export type TicketPriority = "low" | "normal" | "high" | "urgent";
|
||||||
export type TicketChannel = "telegram" | "portal" | "manual" | "email";
|
export type TicketChannel = "telegram" | "portal" | "manual" | "email" | "widget";
|
||||||
export type MessageAuthorType = "customer" | "agent" | "system";
|
export type MessageAuthorType = "customer" | "agent" | "system";
|
||||||
export type MessageDirection = "in" | "out";
|
export type MessageDirection = "in" | "out";
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user