"use client"; import { useCallback, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { Bell, X } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { isTicketVisibleTo } from "@/lib/tickets/visibility"; interface Toast { id: string; title: string; body: string; href: string; } // A single, reused AudioContext. Two separate browser behaviors fight it: // (1) autoplay policy — a context created/resumed outside a real user // gesture starts "suspended" and stays silent; (2) power-saving // auto-suspend — Chrome can suspend an already-unlocked context again // after a stretch of no audio activity, independent of (1). Neither fails // loudly: start()/stop() never throw, the chime just doesn't sound. // Fix: keep the unlock listeners attached for the whole session (not just // the first click) so any later click also re-resumes a context the // browser suspended for power-saving, and actually await resume() before // scheduling a chime instead of assuming it completes synchronously. let sharedAudioContext: AudioContext | null = null; function getAudioContext(): AudioContext | null { if (typeof window === "undefined") return null; const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtor) return null; if (!sharedAudioContext) sharedAudioContext = new AudioCtor(); return sharedAudioContext; } if (typeof window !== "undefined") { const unlockAudio = () => { const ctx = getAudioContext(); if (ctx?.state === "suspended") ctx.resume(); }; window.addEventListener("pointerdown", unlockAudio); window.addEventListener("keydown", unlockAudio); } /** A short two-tone chime via the Web Audio API — no audio asset needed. */ async function playChime() { const ctx = getAudioContext(); if (!ctx) return; try { if (ctx.state === "suspended") await ctx.resume(); if (ctx.state !== "running") return; // still not unlocked by a user gesture yet — nothing to do const now = ctx.currentTime; for (const [freq, start] of [[880, 0], [1175, 0.12]] as const) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; osc.frequency.value = freq; gain.gain.setValueAtTime(0.0001, now + start); gain.gain.exponentialRampToValueAtTime(0.25, now + start + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + start + 0.25); osc.connect(gain); gain.connect(ctx.destination); osc.start(now + start); osc.stop(now + start + 0.25); } } catch { // audio isn't critical to the notification — a silent toast still works } } /** * In-page toast + sound for new tickets and customer messages, shown * regardless of tab focus. Complements DesktopNotifications, which only * fires the native OS notification while the tab is in the background — * this is the "still on the page" case. */ export function TicketToasts({ currentUser }: { currentUser: { id: string; role: "admin" | "agent" } }) { const router = useRouter(); const [toasts, setToasts] = useState([]); const idCounter = useRef(0); const pushToast = useCallback((title: string, body: string, href: string) => { const id = `${Date.now()}-${idCounter.current++}`; setToasts((prev) => [...prev, { id, title, body, href }]); void playChime(); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 6_000); }, []); useTicketEvents("/api/events", (event) => { if (event.type === "ticket.created") { if (!isTicketVisibleTo(event.ticket, currentUser)) return; pushToast("Новая заявка", event.ticket.subject, `/tickets/${event.ticket.id}`); } else if (event.type === "message.created" && event.message.authorType === "customer" && !event.ticketIsNew) { if (!isTicketVisibleTo({ assigneeId: event.assigneeId }, currentUser)) return; pushToast(`Сообщение от ${event.message.authorName}`, event.message.body.slice(0, 120), `/tickets/${event.ticketId}`); } }); function dismiss(id: string) { setToasts((prev) => prev.filter((t) => t.id !== id)); } return (
{toasts.map((toast) => ( ))}
); }