feat(commission) : add push notifications and unread ticket titles
CI / Verify (push) Successful in 1m48s
CI / Build immutable images and deploy (push) Successful in 2m47s

This commit is contained in:
2026-10-02 11:51:05 +07:00 Unverified
parent bb5c571837
commit 3473b55768
27 changed files with 4480 additions and 24 deletions
+21 -5
View File
@@ -2,15 +2,31 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
export function CommissionLiveRefresh({ endpoint }: { endpoint: string }) {
export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string; userId: string }) {
const router = useRouter();
useEffect(() => {
const originalTitle = document.title;
let managedTitle = "";
let unread = 0;
const source = new EventSource(endpoint);
const refresh = () => router.refresh();
const refresh = (event: Event) => {
const message = parseCommissionMessageEvent((event as MessageEvent<string>).data);
if (message && message.authorId !== userId) {
unread += 1;
managedTitle = `(${unread}) ${message.ticketTitle}`;
document.title = managedTitle;
}
router.refresh();
};
const opened = () => router.refresh();
source.addEventListener("changed", refresh);
source.addEventListener("open", refresh);
return () => { source.removeEventListener("changed", refresh); source.removeEventListener("open", refresh); source.close(); };
}, [endpoint, router]);
source.addEventListener("open", opened);
return () => {
source.removeEventListener("changed", refresh); source.removeEventListener("open", opened); source.close();
if (managedTitle && document.title === managedTitle) document.title = originalTitle;
};
}, [endpoint, userId, router]);
return null;
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
import { Bell, BellOff } from "lucide-react";
import { Button } from "@/components/ui/button";
type State = "loading" | "off" | "on" | "blocked" | "unavailable";
export async function unsubscribeCommissionPush() {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration("/");
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return;
await fetch("/api/commission/push-subscription", { method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }) }).catch(() => undefined);
await subscription.unsubscribe();
}
export function CommissionPushPermission({ scope }: { scope: "customer" | "admin" }) {
const [state, setState] = useState<State>("loading");
const [publicKey, setPublicKey] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
void (async () => {
if (!("Notification" in window) || !("serviceWorker" in navigator) ||
!("PushManager" in window) || !window.isSecureContext) {
setState("unavailable"); return;
}
const response = await fetch("/api/commission/push-subscription", { cache: "no-store" });
if (!response.ok) throw new Error();
const result = await response.json() as { publicKey: string };
if (!active) return;
setPublicKey(result.publicKey);
if (Notification.permission === "denied") { setState("blocked"); return; }
if (Notification.permission !== "granted") { setState("off"); return; }
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
const saved = await fetch("/api/commission/push-subscription", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
if (!saved.ok) throw new Error();
}
if (active) setState(subscription ? "on" : "off");
})().catch(() => { if (active) setState("unavailable"); });
return () => { active = false; };
}, [scope]);
async function toggle() {
const previousState = state;
setError(""); setState("loading");
try {
if (Notification.permission === "granted" && state === "on") {
await unsubscribeCommissionPush();
setState("off");
return;
}
if (await Notification.requestPermission() !== "granted") {
setState(Notification.permission === "denied" ? "blocked" : "off");
return;
}
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true,
applicationServerKey: publicKey });
const response = await fetch("/api/commission/push-subscription", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...subscription.toJSON(), scope }) });
if (!response.ok) { await subscription.unsubscribe(); throw new Error(); }
setState("on");
} catch {
setState(previousState === "on" ? "on" : "off");
setError(`${previousState === "on" ? "ปิด" : "เปิด"}การแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง`);
}
}
if (state === "unavailable") return <span className="text-xs text-muted-foreground">
การแจ้งเตือนไม่พร้อมใช้งานบนเบราว์เซอร์นี้ (iPhone/iPad: เพิ่มเว็บไซต์ไปยังหน้าจอโฮมก่อน)
</span>;
if (state === "blocked") return <span className="text-xs text-muted-foreground">เบราว์เซอร์บล็อกการแจ้งเตือนอยู่</span>;
return <div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={state === "loading"} onClick={() => void toggle()}>
{state === "on" ? <BellOff data-icon="inline-start" /> : <Bell data-icon="inline-start" />}
{state === "on" ? "ปิดการแจ้งเตือน" : "เปิดการแจ้งเตือน"}
</Button>
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>;
}
+2
View File
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
import { LogOutIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
import { unsubscribeCommissionPush } from "./push-permission";
export function CommissionSignOut() {
const router = useRouter();
return <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut();
router.push("/commission");
router.refresh();
+31 -5
View File
@@ -19,6 +19,8 @@ import { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, Me
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { CommissionPushPermission } from "./push-permission";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
@@ -55,8 +57,8 @@ function CommissionMessageImage({ src }: { src: string }) {
</Dialog>;
}
export function CommissionTicketChat({ ticketId, userId, status, messages, admin }: {
ticketId: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, messages, admin }: {
ticketId: string; ticketTitle: string; userId: string; status: string; messages: ChatMessage[]; admin: boolean;
}) {
const router = useRouter();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -102,6 +104,18 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
}, [messages, optimisticMessages]);
useEffect(() => {
const originalTitle = document.title;
let managedTitle = ticketTitle;
let unread = 0;
document.title = ticketTitle;
const markRead = () => {
if (document.hidden || !document.hasFocus()) return;
unread = 0;
managedTitle = ticketTitle;
document.title = ticketTitle;
};
document.addEventListener("visibilitychange", markRead);
window.addEventListener("focus", markRead);
const source = new EventSource(`/api/commission/events?ticketId=${ticketId}`);
let typingTimeout: ReturnType<typeof setTimeout> | undefined;
const refresh = () => {
@@ -110,7 +124,15 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
};
const receive = (event: Event) => {
const value = (event as MessageEvent<string>).data;
if (!value.startsWith("typing:")) { refresh(); return; }
if (!value.startsWith("typing:")) {
const message = parseCommissionMessageEvent(value);
if (message && message.authorId !== userId && (document.hidden || !document.hasFocus())) {
unread += 1;
managedTitle = `(${unread}) ${ticketTitle}`;
document.title = managedTitle;
}
refresh(); return;
}
try {
const update = JSON.parse(value.slice(7)) as { userId: string; name: string; active: boolean };
if (update.userId === userId || typeof update.name !== "string" || typeof update.active !== "boolean") return;
@@ -128,8 +150,11 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
source.removeEventListener("changed", receive);
source.removeEventListener("open", refresh);
source.close();
document.removeEventListener("visibilitychange", markRead);
window.removeEventListener("focus", markRead);
if (document.title === managedTitle) document.title = originalTitle;
};
}, [ticketId, userId, router]);
}, [ticketId, ticketTitle, userId, router]);
useEffect(() => {
const viewport = scrollRef.current;
@@ -278,7 +303,8 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
<CardHeader className="border-b py-3">
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
<CardAction className="flex items-center gap-2">
<CardAction className="flex flex-wrap items-center gap-2">
<CommissionPushPermission scope={admin ? "admin" : "customer"} />
<Badge variant={status === "open" ? "secondary" : "outline"}>{status === "open" ? "เปิดอยู่" : "ปิดแล้ว"}</Badge>
{admin && <Button variant="outline" size="sm" onClick={toggleStatus}>
{status === "open" ? "ปิด Ticket" : "เปิดอีกครั้ง"}
+2 -1
View File
@@ -34,7 +34,8 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
</CardContent></Card>
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status}
<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
userId={data.currentUserId} status={data.ticket.status}
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
</div>