fix(commission) : replace repeated notification buttons with entry prompt
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,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></>;
|
||||
}
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user