fix(commission) : replace repeated notification buttons with entry prompt
CI / Verify (push) Successful in 1m36s
CI / Build immutable images and deploy (push) Successful in 2m17s

This commit is contained in:
2026-10-02 12:07:41 +07:00 Unverified
parent 3473b55768
commit 96c27f8fc3
11 changed files with 122 additions and 107 deletions
+1 -1
View File
@@ -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.
-2
View File
@@ -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<"/
<main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1>
<CommissionPushPermission scope="admin" />
</div>
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
{...availability} />
@@ -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();
@@ -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); }
}
-2
View File
@@ -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() {
<CommissionSignOut />
</div>
<Link href="/commission" className="text-sm underline">สร้างคำขอเพิ่ม</Link>
<CommissionPushPermission scope="customer" />
{pending.length > 0 && <h2 className="text-xl font-semibold">รอชำระเงิน</h2>}
{pending.map((checkout) => <Link key={checkout.id} href={`/commission/pay/${checkout.id}`}>
<Card><CardHeader><CardTitle>รอชำระเงิน - ฿{checkout.amount}</CardTitle></CardHeader>
+5 -2
View File
@@ -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 (<>
<CommissionPushEntryPrompt scope="admin" />
<header className="border-b bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center gap-2 px-4 py-2 sm:px-6">
<Link
@@ -124,5 +126,6 @@ export function AdminHeader() {
</nav>
</div>
</header>
</>
);
}
+31
View File
@@ -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();
}
@@ -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<PushRequest | null>(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 <Dialog open={request !== null} onOpenChange={(open) => { if (!open) setRequest(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>รับการแจ้งเตือนข้อความใหม่?</DialogTitle>
<DialogDescription>เมื่อมีข้อความใหม่ใน Ticket เราจะแจ้งเตือนพร้อมชื่อผู้ส่งและข้อความ แม้ปิดแท็บแล้ว</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={() => setRequest(null)}>ไว้ก่อน</Button>
<Button type="button" disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
-90
View File
@@ -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<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 -3
View File
@@ -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 <Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
return <><CommissionPushEntryPrompt scope="customer" /><Button type="button" variant="ghost" size="icon-sm" aria-label="ออกจากระบบ" onClick={async () => {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut();
router.push("/commission");
router.refresh();
}}><LogOutIcon /></Button>;
}}><LogOutIcon /></Button></>;
}
+3 -4
View File
@@ -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 <Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
return <>{!admin && <CommissionPushEntryPrompt scope="customer" />}<Card className="h-svh min-w-0 gap-0 py-0 lg:h-[min(85svh,900px)]">
<CardHeader className="border-b py-3">
<CardTitle>แชต{admin ? "กับลูกค้า" : "กับทีมงาน"}</CardTitle>
<CardDescription>พูดคุยและส่งรูปภาพเกี่ยวกับคำขอนี้</CardDescription>
<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" : "เปิดอีกครั้ง"}
@@ -445,5 +444,5 @@ export function CommissionTicketChat({ ticketId, ticketTitle, userId, status, me
</FieldGroup></form> : <p className="text-center text-sm text-muted-foreground">Ticket นี้ปิดแล้ว หากต้องการพูดคุยต่อ ให้ผู้ดูแลเปิด Ticket อีกครั้ง</p>}
{error && <Alert variant="destructive" className="mt-3"><AlertDescription>{error}</AlertDescription></Alert>}
</CardFooter>
</Card>;
</Card></>;
}