feat : ask for noti
This commit is contained in:
@@ -15,14 +15,13 @@ import {
|
||||
|
||||
import { AdminUnreadCount } from "@/components/comments/admin-unread-comments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SignOutButton } from "@/components/auth/sign-out-button";
|
||||
import { CommissionPushEntryPrompt } from "@/components/commission/push-entry-prompt";
|
||||
import { PublicAccountMenu } from "@/components/public/public-account-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
|
||||
export function AdminHeader() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (<>
|
||||
{pathname.startsWith("/admin/commission") && <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-nowrap items-center gap-2 px-4 py-2 sm:px-6">
|
||||
<Link
|
||||
@@ -104,8 +103,9 @@ export function AdminHeader() {
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
<SignOutButton admin />
|
||||
</nav>
|
||||
<NotificationBell />
|
||||
<PublicAccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
|
||||
@@ -9,18 +9,20 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { CommentPage } from "@/lib/comments/types";
|
||||
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMarkCommentsRead } from "./admin-unread-comments";
|
||||
import { CommentPushPrompt } from "./comment-push-prompt";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentCard, CommentThread } from "./comment-card";
|
||||
import { CommentSkeleton } from "./comment-skeleton";
|
||||
import { useCommentEvents } from "./use-comment-events";
|
||||
import { useCommentFeed } from "./use-comment-feed";
|
||||
|
||||
type TargetOption = { value: string; label: string; image: string | null; rarity: number | null; characterKey: string | null };
|
||||
export function AdminCommentInbox({ initial, targets, initialTarget }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string }) {
|
||||
export function AdminCommentInbox({ initial, targets, initialTarget, initialComment }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string; initialComment?: string }) {
|
||||
const [notificationOpen, setNotificationOpen] = useState(Boolean(initialComment));
|
||||
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [answered, setAnswered] = useState("all");
|
||||
@@ -31,7 +33,11 @@ export function AdminCommentInbox({ initial, targets, initialTarget }: { initial
|
||||
const markRead = useMarkCommentsRead();
|
||||
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
|
||||
return <div className="flex flex-col gap-6">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin />}
|
||||
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
|
||||
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
|
||||
</DialogContent></Dialog>}
|
||||
<FieldGroup className="md:grid md:grid-cols-3">
|
||||
<Field><FieldLabel>ไกด์หรือรอบ Stygian</FieldLabel>
|
||||
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
|
||||
return () => window.removeEventListener("hashchange", showThread);
|
||||
}, []);
|
||||
return <div className="flex flex-col gap-6 py-2">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin={feed.page.viewer.admin} />}
|
||||
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
|
||||
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ</p><Button onClick={() => router.push(commentSignInHref())}>เข้าสู่ระบบ</Button></div>}</div></div>
|
||||
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">ความคิดเห็น</h2>
|
||||
|
||||
@@ -256,6 +256,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" className="aspect-video w-full object-cover" />
|
||||
</>}
|
||||
{item.target.startsWith("stygian:") && <Badge variant="secondary" className="whitespace-normal">{item.targetName}</Badge>}
|
||||
</Link>}
|
||||
</div>
|
||||
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
|
||||
|
||||
@@ -96,7 +96,6 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
|
||||
});
|
||||
setText(""); setFiles([]); setKept([]); setExpanded(false);
|
||||
toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
|
||||
window.dispatchEvent(new Event("comment:posted"));
|
||||
onSaved();
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
|
||||
finally { setPending(false); uploadRef.current = null; }
|
||||
|
||||
@@ -1,72 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
export function CommentPushPrompt({ userId }: { userId: string }) {
|
||||
const [publicKey, setPublicKey] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const askedKey = `comment-push-asked-${userId}`;
|
||||
const syncedKey = `comment-push-synced-${userId}`;
|
||||
const syncPush = useCallback(async (key: string) => {
|
||||
// Reuse the site's existing root service worker so ticket notifications keep working.
|
||||
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
|
||||
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
|
||||
const response = await fetch("/api/comments/push-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription.toJSON()) });
|
||||
if (!response.ok) throw new Error("subscription-failed");
|
||||
sessionStorage.setItem(syncedKey, "1");
|
||||
}, [syncedKey]);
|
||||
useEffect(() => {
|
||||
if (!window.isSecureContext || !("Notification" in window) || !("PushManager" in window) || !("serviceWorker" in navigator)) return;
|
||||
let active = true;
|
||||
void fetch("/api/comments/push-subscription", { cache: "no-store" }).then(async (response) => {
|
||||
if (!response.ok) return;
|
||||
const config = await response.json() as { publicKey: string };
|
||||
if (!active) return;
|
||||
setPublicKey(config.publicKey);
|
||||
if (Notification.permission === "granted") {
|
||||
if (!sessionStorage.getItem(syncedKey)) await syncPush(config.publicKey);
|
||||
if (active) setEnabled(true);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, [userId, syncedKey, syncPush]);
|
||||
|
||||
useEffect(() => {
|
||||
function ask() {
|
||||
if (!publicKey || Notification.permission !== "default" || localStorage.getItem(askedKey)) return;
|
||||
localStorage.setItem(askedKey, "1");
|
||||
setOpen(true);
|
||||
}
|
||||
window.addEventListener("comment:posted", ask);
|
||||
return () => window.removeEventListener("comment:posted", ask);
|
||||
}, [publicKey, askedKey]);
|
||||
|
||||
async function enable() {
|
||||
if (!publicKey) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
if (await Notification.requestPermission() !== "granted") {
|
||||
setError("ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์การแจ้งเตือนในการตั้งค่าเว็บไซต์ของเบราว์เซอร์");
|
||||
return;
|
||||
}
|
||||
await syncPush(publicKey);
|
||||
setEnabled(true); setOpen(false);
|
||||
} catch { setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
if (!publicKey || enabled) return null;
|
||||
return <>
|
||||
<Button variant="outline" size="sm" className="self-start" onClick={() => setOpen(true)}><BellIcon data-icon="inline-start" />แจ้งเตือนเมื่อมีคนตอบกลับ</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}><DialogContent>
|
||||
<DialogHeader><DialogTitle>รับการแจ้งเตือนเมื่อมีคนตอบกลับ?</DialogTitle><DialogDescription>เราจะแจ้งเตือนเมื่อมีคนตอบกลับความคิดเห็นของคุณ แม้ปิดแท็บแล้ว กดที่การแจ้งเตือนเพื่อไปยังคำตอบนั้น</DialogDescription></DialogHeader>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setOpen(false)}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
|
||||
</DialogContent></Dialog>
|
||||
</>;
|
||||
'use client';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
|
||||
return <Button variant="outline" size="sm" className="self-start" onClick={() => window.dispatchEvent(new Event('notifications:enable'))}><BellIcon data-icon="inline-start" />{admin ? 'แจ้งเตือนความคิดเห็นใหม่' : 'แจ้งเตือนเมื่อมีคนตอบกลับ'}</Button>;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
"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");
|
||||
for (const key of Object.keys(sessionStorage)) if (key.startsWith("comment-push-synced-")) sessionStorage.removeItem(key);
|
||||
for (const key of Object.keys(sessionStorage)) if (key.startsWith("comment-push-synced-") || key.startsWith("site-push-synced-")) sessionStorage.removeItem(key);
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
|
||||
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
|
||||
import { keepCommissionTitle } from "./keep-title";
|
||||
|
||||
type ChatMessage = {
|
||||
@@ -626,7 +625,6 @@ export function CommissionTicketChat({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!admin && <CommissionPushEntryPrompt scope="customer" />}
|
||||
<AlertDialog open={closedAlertOpen} onOpenChange={setClosedAlertOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BellIcon, SettingsIcon } from 'lucide-react';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Item = { id: string; title: string; body: string; url: string; readAt: string | null };
|
||||
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
|
||||
const empty: Feed = { items: [], unread: 0, nextCursor: null };
|
||||
type State = Feed & { signedIn: boolean; busy: boolean; error: string; refresh: (more?: boolean) => Promise<void>; read: (id?: string) => Promise<void> };
|
||||
const Context = createContext<State>({ ...empty, signedIn: false, busy: false, error: '', refresh: async () => {}, read: async () => {} });
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const { data } = authClient.useSession();
|
||||
const userId = data?.user.id;
|
||||
const [feed, setFeed] = useState<Feed>(empty);
|
||||
const [owner, setOwner] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const current = useRef(feed);
|
||||
useEffect(() => { current.current = feed; }, [feed]);
|
||||
const sequence = useRef(0);
|
||||
const invalidate = useCallback(() => { ++sequence.current; }, []);
|
||||
const refresh = useCallback(async (more = false) => {
|
||||
if (!userId) return;
|
||||
const request = ++sequence.current;
|
||||
const cursor = more ? current.current.nextCursor : null;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch(`/api/notifications${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('โหลดการแจ้งเตือนไม่สำเร็จ');
|
||||
const result = await response.json() as Feed;
|
||||
if (sequence.current !== request) return;
|
||||
setFeed(previous => ({ ...result, items: more ? [...previous.items, ...result.items.filter(item => !previous.items.some(old => old.id === item.id))] : result.items }));
|
||||
setOwner(userId); setError('');
|
||||
} catch (cause) { if (sequence.current === request) setError(cause instanceof Error ? cause.message : 'โหลดการแจ้งเตือนไม่สำเร็จ'); }
|
||||
finally { if (sequence.current === request) setBusy(false); }
|
||||
}, [userId]);
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
void Promise.resolve().then(() => refresh());
|
||||
const source = new EventSource('/api/notifications/events');
|
||||
source.addEventListener('changed', () => void refresh());
|
||||
source.addEventListener('open', () => void refresh());
|
||||
return () => { invalidate(); source.close(); };
|
||||
}, [userId, refresh, invalidate]);
|
||||
const read = useCallback(async (id?: string) => {
|
||||
if (!userId) return;
|
||||
setFeed(previous => ({ ...previous, unread: id ? Math.max(0, previous.unread - (previous.items.some(item => item.id === id && !item.readAt) ? 1 : 0)) : 0, items: previous.items.map(item => !id || item.id === id ? { ...item, readAt: new Date().toISOString() } : item) }));
|
||||
try {
|
||||
const response = await fetch('/api/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(id ? { id } : {}) });
|
||||
if (!response.ok) throw new Error('บันทึกการอ่านไม่สำเร็จ');
|
||||
} finally { await refresh(); }
|
||||
}, [userId, refresh]);
|
||||
return <Context value={{ ...(owner === userId ? feed : empty), signedIn: Boolean(userId), busy, error, refresh, read }}>{children}</Context>;
|
||||
}
|
||||
export function NotificationBell() {
|
||||
const state = useContext(Context);
|
||||
const [open, setOpen] = useState(false);
|
||||
return <Popover open={open} onOpenChange={value => { setOpen(value); if (value) void state.refresh(); }}>
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}><BellIcon />
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full px-1">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
|
||||
{!state.signedIn ? <Link href="/auth/login" className="text-sm text-primary underline">เข้าสู่ระบบเพื่อดูการแจ้งเตือน</Link> : <>
|
||||
{state.unread > 0 && <Button variant="link" size="sm" className="self-start" onClick={() => void state.read().catch(() => undefined)}>อ่านทั้งหมด</Button>}
|
||||
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
|
||||
{state.items.map(item => <Link key={item.id} href={item.url} prefetch={false} className={cn('rounded-lg p-3', !item.readAt ? 'bg-accent' : 'hover:bg-muted')} onClick={() => { setOpen(false); void state.read(item.id).catch(() => undefined); }}><p className="text-sm font-medium">{!item.readAt && <span className="mr-1 text-destructive" aria-label="ยังไม่ได้อ่าน">●</span>}{item.title}</p><p className="line-clamp-2 break-words text-xs text-muted-foreground">{item.body}</p></Link>)}
|
||||
{state.busy && !state.items.length && <div role="status" aria-label="กำลังโหลดการแจ้งเตือน" className="flex flex-col gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>}
|
||||
{!state.busy && !state.items.length && !state.error && <p className="py-6 text-center text-sm text-muted-foreground">ยังไม่มีการแจ้งเตือน</p>}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
|
||||
{state.nextCursor && <Button variant="ghost" disabled={state.busy} onClick={() => void state.refresh(true)}>แสดงเพิ่มเติม</Button>}
|
||||
</div>
|
||||
</>}
|
||||
</PopoverContent>
|
||||
</Popover>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
// All notification features share one browser permission and root service worker.
|
||||
export async function syncSitePush(userId: string) {
|
||||
const configResponse = await fetch('/api/commission/push-subscription', { cache: 'no-store' });
|
||||
if (!configResponse.ok) throw new Error('push-config-failed');
|
||||
const config = await configResponse.json() as { publicKey: string; canAdmin: boolean };
|
||||
const registration = await navigator.serviceWorker.register('/commission-push-sw.js', { scope: '/' });
|
||||
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: config.publicKey });
|
||||
const data = subscription.toJSON();
|
||||
const registrations = [
|
||||
{ url: '/api/comments/push-subscription', body: data },
|
||||
{ url: '/api/commission/push-subscription', body: { ...data, scope: config.canAdmin ? 'admin' : 'customer' } },
|
||||
];
|
||||
const responses = await Promise.all(registrations.map(feature => fetch(feature.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feature.body) })));
|
||||
if (responses.some(response => !response.ok)) throw new Error('push-subscription-failed');
|
||||
sessionStorage.setItem(`site-push-synced-${userId}`, '1');
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Field, FieldLabel } from '@/components/ui/field';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { syncSitePush } from './push-client';
|
||||
|
||||
const dismissedKey = 'site-push-dont-ask';
|
||||
const askedKey = 'site-push-asked';
|
||||
export function SitePushPrompt({ configured }: { configured: boolean }) {
|
||||
const { data } = authClient.useSession();
|
||||
const userId = data?.user.id;
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dontAsk, setDontAsk] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
if (!configured || !window.isSecureContext || !('Notification' in window) || !('PushManager' in window) || !('serviceWorker' in navigator)) return;
|
||||
let active = true;
|
||||
function show() { setError(''); setOpen(true); }
|
||||
window.addEventListener('notifications:enable', show);
|
||||
void Promise.resolve().then(async () => {
|
||||
if (!active) return;
|
||||
if (Notification.permission === 'granted') {
|
||||
if (userId && !sessionStorage.getItem(`site-push-synced-${userId}`)) await syncSitePush(userId);
|
||||
} else if (Notification.permission === 'default' && !localStorage.getItem(dismissedKey) && !sessionStorage.getItem(askedKey)) {
|
||||
sessionStorage.setItem(askedKey, '1');
|
||||
setOpen(true);
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
return () => { active = false; window.removeEventListener('notifications:enable', show); };
|
||||
}, [configured, userId]);
|
||||
function close() {
|
||||
if (dontAsk) localStorage.setItem(dismissedKey, '1');
|
||||
setOpen(false);
|
||||
}
|
||||
async function enable() {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
if (await Notification.requestPermission() !== 'granted') {
|
||||
setError('ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์ในการตั้งค่าเว็บไซต์ของเบราว์เซอร์');
|
||||
return;
|
||||
}
|
||||
if (userId) await syncSitePush(userId);
|
||||
close();
|
||||
} catch { setError('เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <Dialog open={open} onOpenChange={value => { if (!value && !busy) close(); }}><DialogContent>
|
||||
<DialogHeader><DialogTitle>รับการแจ้งเตือนจากเว็บไซต์?</DialogTitle><DialogDescription>รับข้อความ Commission คำตอบกลับความคิดเห็น และการแจ้งเตือนอื่นๆ ของเว็บไซต์ในอนาคต แม้ปิดแท็บแล้ว</DialogDescription></DialogHeader>
|
||||
{!userId && <p className="text-sm text-muted-foreground">หลังเข้าสู่ระบบ เราจะเชื่อมการแจ้งเตือนกับบัญชีของคุณ</p>}
|
||||
<Field orientation="horizontal"><Checkbox id={id} checked={dontAsk} onCheckedChange={setDontAsk} disabled={busy} /><FieldLabel htmlFor={id}>ไม่ต้องถามอีก</FieldLabel></Field>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={close}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
|
||||
</DialogContent></Dialog>;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { VisitorPresence } from "@/components/public/visitor-presence";
|
||||
import { PublicNavigation } from "@/components/public/public-navigation";
|
||||
import { PublicAccountMenu } from "@/components/public/public-account-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
|
||||
@@ -45,6 +46,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
|
||||
<Suspense fallback={null}>
|
||||
<AdminEditLink editHref={editHref} />
|
||||
</Suspense>
|
||||
<NotificationBell />
|
||||
<PublicAccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user