feat : ask for noti
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 2m59s

This commit is contained in:
2026-10-08 02:23:59 +07:00 Unverified
parent 5812d99715
commit 9bc710c94b
34 changed files with 10913 additions and 201 deletions
@@ -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>;
}
+18
View File
@@ -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>;
}