2026-01-26 16:04:27 +01:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
|
|
|
|
|
import { onMount } from 'svelte';
|
2026-01-26 17:19:06 +01:00
|
|
|
import { goto } from '$app/navigation';
|
2026-01-26 16:04:27 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 17:19:06 +01:00
|
|
|
async function handleNotificationClick(notification: Notification) {
|
2026-01-26 16:04:27 +01:00
|
|
|
if (!notification.read_at) {
|
|
|
|
|
markAsRead(notification.id);
|
|
|
|
|
}
|
2026-01-26 17:19:06 +01:00
|
|
|
closeDropdown();
|
2026-01-26 16:04:27 +01:00
|
|
|
if (notification.link) {
|
2026-01-26 17:19:06 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-01-26 16:04:27 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|