Files
top-tickets/src/app/(admin)/ticket-toasts.tsx
T
ogrechko b2534b7ce8 Fix duplicate new-ticket notification (toast + desktop push both fired)
appendMessage() publishes ticket.created and message.created for the
same first message of a new ticket. TicketToasts and DesktopNotifications
listened to both independently, so one new customer ticket produced two
toasts/two chimes/two OS notifications. message.created now carries
ticketIsNew so both subscribers can skip it when ticket.created already
covered the same event.
2026-08-14 05:50:29 +00:00

139 lines
5.6 KiB
TypeScript

"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<Toast[]>([]);
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 (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="card pointer-events-auto flex items-start gap-2 p-3 shadow-md"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
<Bell size={13} />
</span>
<button
onClick={() => {
dismiss(toast.id);
router.push(toast.href);
}}
className="min-w-0 flex-1 text-left"
>
<p className="truncate text-sm font-medium">{toast.title}</p>
<p className="line-clamp-2 text-xs text-text-muted">{toast.body}</p>
</button>
<button onClick={() => dismiss(toast.id)} className="text-text-faint hover:text-text" aria-label="Закрыть">
<X size={13} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}