feat(commission) : add push notifications and unread ticket titles
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { unsubscribeCommissionPush } from "@/components/commission/push-permission";
|
||||
|
||||
export function AdminHeader() {
|
||||
const pathname = usePathname();
|
||||
@@ -106,16 +107,17 @@ export function AdminHeader() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="ออกจากระบบ"
|
||||
onClick={() =>
|
||||
authClient.signOut({
|
||||
onClick={async () => {
|
||||
await unsubscribeCommissionPush().catch(() => undefined);
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/admin/login");
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LogOutIcon />
|
||||
</Button>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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" : "เปิดอีกครั้ง"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user