diff --git a/README.md b/README.md index 45ae870..c83a9a7 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ the commission database migration before enabling checkout. Commission message notifications require a stable VAPID key pair and subject (`bunx web-push generate-vapid-keys --json`). Set the three `WEB_PUSH_*` values in the web deployment secret and apply the push-subscription migration before -enabling notifications. Users opt in from their ticket page. Browser push can +enabling notifications. Users opt in from a prompt when entering the commission area. Browser push can arrive after tabs close; on iPhone and iPad, the site must be added to the Home Screen before the browser offers push permission. diff --git a/app/admin/commission/page.tsx b/app/admin/commission/page.tsx index 52b32ca..fa4a2f3 100644 --- a/app/admin/commission/page.tsx +++ b/app/admin/commission/page.tsx @@ -6,7 +6,6 @@ import { getDb } from "@/db"; import { commissionCheckouts, commissionTickets, users } from "@/db/schema"; import { AdminHeader } from "@/components/admin/admin-header"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; -import { CommissionPushPermission } from "@/components/commission/push-permission"; import { AdminCommissionAvailabilityControl } from "@/components/commission/admin-availability-control"; import { Badge } from "@/components/ui/badge"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -31,7 +30,6 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/

Commission tickets

-
diff --git a/app/api/commission/push-subscription/route.test.ts b/app/api/commission/push-subscription/route.test.ts index 7b579d2..e6ffe5e 100644 --- a/app/api/commission/push-subscription/route.test.ts +++ b/app/api/commission/push-subscription/route.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/commission/push", () => ({ validPushEndpoint: (value: string) => value.startsWith("https://fcm.googleapis.com/"), })); -const { POST } = await import("./route"); +const { GET, POST } = await import("./route"); const subscription = { endpoint: "https://fcm.googleapis.com/fcm/send/token", keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) } }; @@ -40,6 +40,14 @@ describe("commission push subscriptions", () => { expect(insert).not.toHaveBeenCalled(); }); + it("only offers admin push scope to verified admins", async () => { + expect(await (await GET()).json()).toEqual({ publicKey: "public-key", canAdmin: false }); + requireCommissionUser.mockResolvedValue({ id: "admin-1", role: "admin", emailVerified: false }); + expect(await (await GET()).json()).toEqual({ publicKey: "public-key", canAdmin: false }); + requireCommissionUser.mockResolvedValue({ id: "admin-1", role: "admin", emailVerified: true }); + expect(await (await GET()).json()).toEqual({ publicKey: "public-key", canAdmin: true }); + }); + it("rejects a push endpoint outside browser push services", async () => { expect((await POST(request("customer", "https://internal.example/push"))).status).toBe(400); expect(insert).not.toHaveBeenCalled(); diff --git a/app/api/commission/push-subscription/route.ts b/app/api/commission/push-subscription/route.ts index 3af73ed..b68b1ba 100644 --- a/app/api/commission/push-subscription/route.ts +++ b/app/api/commission/push-subscription/route.ts @@ -16,10 +16,11 @@ const subscriptionSchema = z.object({ export async function GET() { try { - await requireCommissionUser(); + const user = await requireCommissionUser(); const publicKey = pushPublicKey(); if (!publicKey) throw new HttpError(503, "push-not-configured"); - return Response.json({ publicKey }, { headers: { "Cache-Control": "no-store" } }); + return Response.json({ publicKey, canAdmin: user.role === "admin" && user.emailVerified }, + { headers: { "Cache-Control": "no-store" } }); } catch (cause) { return errorResponse(cause); } } diff --git a/app/commission/tickets/page.tsx b/app/commission/tickets/page.tsx index 7e96d3f..465b412 100644 --- a/app/commission/tickets/page.tsx +++ b/app/commission/tickets/page.tsx @@ -5,7 +5,6 @@ import { connection } from "next/server"; import { getDb } from "@/db"; import { commissionCheckouts, commissionTickets } from "@/db/schema"; import { CommissionLiveRefresh } from "@/components/commission/live-refresh"; -import { CommissionPushPermission } from "@/components/commission/push-permission"; import { CommissionSignOut } from "@/components/commission/sign-out"; import { SiteHeader } from "@/components/public/site-header"; import { Badge } from "@/components/ui/badge"; @@ -36,7 +35,6 @@ export default async function CommissionTicketsPage() { สร้างคำขอเพิ่ม - {pending.length > 0 &&

รอชำระเงิน

} {pending.map((checkout) => รอชำระเงิน - ฿{checkout.amount} diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index ed4f20f..53bb720 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -17,13 +17,15 @@ import { import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth/client"; -import { unsubscribeCommissionPush } from "@/components/commission/push-permission"; +import { unsubscribeCommissionPush } from "@/components/commission/push-client"; +import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt"; export function AdminHeader() { const pathname = usePathname(); const router = useRouter(); - return ( + return (<> +
+ ); } diff --git a/components/commission/push-client.ts b/components/commission/push-client.ts new file mode 100644 index 0000000..ab9d162 --- /dev/null +++ b/components/commission/push-client.ts @@ -0,0 +1,31 @@ +"use client"; + +export async function subscribeCommissionPush(scope: "customer" | "admin", publicKey: string) { + if (await Notification.requestPermission() !== "granted") return false; + await syncCommissionPush(scope, publicKey); + return true; +} + +export async function syncCommissionPush(scope: "customer" | "admin", publicKey: string) { + const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" }); + const subscription = await registration.pushManager.getSubscription() ?? + 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) throw new Error("Could not save push subscription"); + sessionStorage.setItem(`commission-push-synced-${scope}`, "1"); +} + +export async function unsubscribeCommissionPush() { + sessionStorage.removeItem("commission-push-synced-customer"); + sessionStorage.removeItem("commission-push-synced-admin"); + 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(); +} diff --git a/components/commission/push-entry-prompt.tsx b/components/commission/push-entry-prompt.tsx new file mode 100644 index 0000000..93eec55 --- /dev/null +++ b/components/commission/push-entry-prompt.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { subscribeCommissionPush, syncCommissionPush } from "./push-client"; + +type PushRequest = { scope: "customer" | "admin"; publicKey: string }; +const askedKey = "commission-push-asked"; + +export function CommissionPushEntryPrompt({ scope }: { scope: "customer" | "admin" }) { + const [request, setRequest] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!window.isSecureContext || + !("Notification" in window) || !("PushManager" in window) || !("serviceWorker" in navigator) || + Notification.permission === "denied" || + (Notification.permission === "default" && sessionStorage.getItem(askedKey)) || + (Notification.permission === "granted" && (sessionStorage.getItem(`commission-push-synced-${scope}`) || + (scope === "customer" && sessionStorage.getItem("commission-push-synced-admin"))))) return; + + let active = true; + void fetch("/api/commission/push-subscription", { cache: "no-store" }).then(async (response) => { + if (!response.ok) return; + const config = await response.json() as { publicKey: string; canAdmin: boolean }; + if (!active || (scope === "admin" && !config.canAdmin)) return; + const subscriptionScope = config.canAdmin ? "admin" : scope; + if (Notification.permission === "granted") { + await syncCommissionPush(subscriptionScope, config.publicKey); + return; + } + if (Notification.permission !== "default") return; + sessionStorage.setItem(askedKey, "1"); + setRequest({ scope: subscriptionScope, publicKey: config.publicKey }); + }).catch(() => undefined); + return () => { active = false; }; + }, [scope]); + + async function enable() { + if (!request) return; + setBusy(true); + setError(""); + try { + await subscribeCommissionPush(request.scope, request.publicKey); + setRequest(null); + } catch { + setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง"); + } finally { setBusy(false); } + } + + return { if (!open) setRequest(null); }}> + + + รับการแจ้งเตือนข้อความใหม่? + เมื่อมีข้อความใหม่ใน Ticket เราจะแจ้งเตือนพร้อมชื่อผู้ส่งและข้อความ แม้ปิดแท็บแล้ว + + {error &&

{error}

} + + + + +
+
; +} diff --git a/components/commission/push-permission.tsx b/components/commission/push-permission.tsx deleted file mode 100644 index 5c590e5..0000000 --- a/components/commission/push-permission.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"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("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 - การแจ้งเตือนไม่พร้อมใช้งานบนเบราว์เซอร์นี้ (iPhone/iPad: เพิ่มเว็บไซต์ไปยังหน้าจอโฮมก่อน) - ; - if (state === "blocked") return เบราว์เซอร์บล็อกการแจ้งเตือนอยู่; - return
- - {error && {error}} -
; -} diff --git a/components/commission/sign-out.tsx b/components/commission/sign-out.tsx index 11f36bd..4b728bc 100644 --- a/components/commission/sign-out.tsx +++ b/components/commission/sign-out.tsx @@ -4,14 +4,15 @@ 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"; +import { unsubscribeCommissionPush } from "./push-client"; +import { CommissionPushEntryPrompt } from "./push-entry-prompt"; export function CommissionSignOut() { const router = useRouter(); - return ; + }}>; } diff --git a/components/commission/ticket-chat.tsx b/components/commission/ticket-chat.tsx index d530deb..bf880d1 100644 --- a/components/commission/ticket-chat.tsx +++ b/components/commission/ticket-chat.tsx @@ -20,7 +20,7 @@ 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"; +import { CommissionPushEntryPrompt } from "./push-entry-prompt"; type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null; imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] }; @@ -299,12 +299,11 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me } } - return + return <>{!admin && } แชต{admin ? "กับลูกค้า" : "กับทีมงาน"} พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้ - {status === "open" ? "เปิดอยู่" : "ปิดแล้ว"} {admin &&