monacousa-portal/src/lib/components/layout/NotificationCenter.svelte

252 lines
7.4 KiB
Svelte

<script lang="ts">
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface Notification {
id: string;
type: 'welcome' | 'event' | 'payment' | 'membership' | 'system' | 'announcement';
title: string;
message: string;
link: string | null;
read_at: string | null;
created_at: string;
}
let isOpen = $state(false);
let notifications = $state<Notification[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const unreadCount = $derived(notifications.filter(n => !n.read_at).length);
function toggleDropdown() {
isOpen = !isOpen;
if (isOpen && notifications.length === 0) {
loadNotifications();
}
}
function closeDropdown() {
isOpen = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.notification-center')) {
closeDropdown();
}
}
async function loadNotifications() {
loading = true;
error = null;
try {
const response = await fetch('/api/notifications');
if (!response.ok) {
throw new Error('Failed to load notifications');
}
const data = await response.json();
notifications = data.notifications || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load notifications';
} finally {
loading = false;
}
}
async function markAsRead(id: string) {
try {
const response = await fetch(`/api/notifications/${id}/read`, {
method: 'POST'
});
if (response.ok) {
notifications = notifications.map(n =>
n.id === id ? { ...n, read_at: new Date().toISOString() } : n
);
}
} catch (e) {
console.error('Failed to mark notification as read:', e);
}
}
async function markAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST'
});
if (response.ok) {
const now = new Date().toISOString();
notifications = notifications.map(n => ({ ...n, read_at: n.read_at || now }));
}
} catch (e) {
console.error('Failed to mark all notifications as read:', e);
}
}
async function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
markAsRead(notification.id);
}
closeDropdown();
if (notification.link) {
// Use goto for internal links, window.location for external
if (notification.link.startsWith('/')) {
await goto(notification.link);
} else if (notification.link.startsWith('http')) {
window.location.href = notification.link;
}
}
}
function getTypeIcon(type: Notification['type']) {
switch (type) {
case 'welcome': return Users;
case 'event': return Calendar;
case 'payment': return CreditCard;
case 'membership': return Users;
case 'system': return Settings;
case 'announcement': return Megaphone;
default: return Bell;
}
}
function getTypeColor(type: Notification['type']) {
switch (type) {
case 'welcome': return 'text-green-600 bg-green-100';
case 'event': return 'text-blue-600 bg-blue-100';
case 'payment': return 'text-amber-600 bg-amber-100';
case 'membership': return 'text-purple-600 bg-purple-100';
case 'system': return 'text-slate-600 bg-slate-100';
case 'announcement': return 'text-monaco-600 bg-monaco-100';
default: return 'text-slate-600 bg-slate-100';
}
}
function formatTime(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
onMount(() => {
loadNotifications();
});
</script>
<svelte:window onclick={handleClickOutside} />
<div class="notification-center relative">
<!-- Bell Button -->
<button
onclick={toggleDropdown}
class="relative rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 transition-colors min-h-[44px] min-w-[44px]"
aria-label="Notifications"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Bell class="h-5 w-5" />
{#if unreadCount > 0}
<span
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-monaco-600 text-[10px] font-medium text-white"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
{/if}
</button>
<!-- Dropdown -->
{#if isOpen}
<div
class="absolute right-0 top-full mt-2 w-80 sm:w-96 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg ring-1 ring-black/5 z-50"
role="menu"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 class="font-semibold text-slate-900">Notifications</h3>
{#if unreadCount > 0}
<button
onclick={markAllAsRead}
class="flex items-center gap-1 text-xs text-monaco-600 hover:text-monaco-700 transition-colors"
>
<CheckCheck class="h-3.5 w-3.5" />
Mark all read
</button>
{/if}
</div>
<!-- Notifications List -->
<div class="max-h-96 overflow-y-auto">
{#if loading}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-monaco-600 border-t-transparent"></div>
</div>
{:else if error}
<div class="px-4 py-8 text-center">
<p class="text-sm text-red-600">{error}</p>
<button
onclick={loadNotifications}
class="mt-2 text-sm text-monaco-600 hover:text-monaco-700"
>
Try again
</button>
</div>
{:else if notifications.length === 0}
<div class="px-4 py-8 text-center">
<Bell class="mx-auto h-8 w-8 text-slate-300" />
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
</div>
{:else}
{#each notifications as notification}
{@const Icon = getTypeIcon(notification.type)}
<button
onclick={() => handleNotificationClick(notification)}
class="flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors {notification.read_at ? 'opacity-60' : ''}"
role="menuitem"
>
<div class="flex-shrink-0 rounded-full p-2 {getTypeColor(notification.type)}">
<Icon class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-slate-900 {notification.read_at ? '' : 'font-semibold'}">
{notification.title}
</p>
{#if !notification.read_at}
<span class="flex-shrink-0 h-2 w-2 rounded-full bg-monaco-600"></span>
{/if}
</div>
<p class="mt-0.5 text-xs text-slate-600 line-clamp-2">{notification.message}</p>
<p class="mt-1 text-xs text-slate-400">{formatTime(notification.created_at)}</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
{#if notifications.length > 0}
<div class="border-t border-slate-100 px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="block text-center text-sm text-monaco-600 hover:text-monaco-700 transition-colors py-1"
>
View all notifications
</a>
</div>
{/if}
</div>
{/if}
</div>