Add notifications pages and fix RLS/email issues
Build and Push Docker Image / build (push) Successful in 2m7s Details

- Fix RLS policies: Add WITH CHECK clause to all FOR ALL policies
  (fixes 502 errors on admin settings and other updates)
- Add /notifications page for users to view all notifications
- Add /admin/notifications page for admins to create/manage notifications
- Add notifications link to admin sidebar
- Fix NotificationCenter to use goto() for internal navigation
- Fix email.ts to fall back to environment variables for SMTP
  (allows welcome emails to work when app_settings SMTP not configured)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-26 17:19:06 +01:00
parent 0053fa2b5e
commit 4e3cf89f62
8 changed files with 800 additions and 29 deletions

View File

@ -749,6 +749,9 @@ CREATE POLICY "Board can manage events"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
);
-- EVENT RSVPs POLICIES
@ -771,6 +774,9 @@ CREATE POLICY "Board can manage all RSVPs"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
);
-- PUBLIC RSVPs POLICIES
@ -791,6 +797,9 @@ CREATE POLICY "Board can manage public RSVPs"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
);
-- DOCUMENTS POLICIES
@ -821,6 +830,9 @@ CREATE POLICY "Admin can manage all documents"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- APP SETTINGS POLICIES
@ -840,6 +852,9 @@ CREATE POLICY "Admin can manage settings"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- EMAIL TEMPLATES POLICIES
@ -848,6 +863,9 @@ CREATE POLICY "Admin can manage email templates"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- EMAIL LOGS POLICIES
@ -864,6 +882,9 @@ CREATE POLICY "Admin can manage email logs"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- INDEXES
@ -1456,6 +1477,9 @@ CREATE POLICY "Admin can manage all notifications"
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- Grant permissions

View File

@ -1,6 +1,7 @@
<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;
@ -84,14 +85,19 @@
}
}
function handleNotificationClick(notification: Notification) {
async function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
markAsRead(notification.id);
}
if (notification.link) {
window.location.href = notification.link;
}
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']) {

View File

@ -14,7 +14,8 @@
Shield,
FolderOpen,
DollarSign,
CalendarPlus
CalendarPlus,
Bell
} from 'lucide-svelte';
import type { MemberWithDues } from '$lib/types/database';
@ -55,6 +56,7 @@
const adminNav: NavItem[] = [
{ href: '/admin/members', label: 'User Management', icon: Shield },
{ href: '/admin/notifications', label: 'Notifications', icon: Bell },
{ href: '/admin/settings', label: 'Settings', icon: Settings }
];

View File

@ -25,43 +25,61 @@ export interface SendEmailOptions {
}
/**
* Get SMTP configuration from app_settings table
* Get SMTP configuration from app_settings table, with fallback to environment variables
* This allows welcome emails to work using the same SMTP as GoTrue when app_settings isn't configured
*/
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
// First try to get from app_settings
const { data: settings } = await supabaseAdmin
.from('app_settings')
.select('setting_key, setting_value')
.eq('category', 'email');
if (!settings || settings.length === 0) {
return null;
}
const config: Record<string, string> = {};
for (const s of settings) {
// Parse the value - it might be JSON stringified or plain
let value = s.setting_value;
if (typeof value === 'string') {
// Remove surrounding quotes if present
value = value.replace(/^"|"$/g, '');
if (settings && settings.length > 0) {
for (const s of settings) {
// Parse the value - it might be JSON stringified or plain
let value = s.setting_value;
if (typeof value === 'string') {
// Remove surrounding quotes if present
value = value.replace(/^"|"$/g, '');
}
config[s.setting_key] = value as string;
}
config[s.setting_key] = value as string;
}
// Validate required fields
if (!config.smtp_host || !config.smtp_username || !config.smtp_password) {
return null;
// Check if app_settings has valid SMTP config
if (config.smtp_host && config.smtp_username && config.smtp_password) {
return {
host: config.smtp_host,
port: parseInt(config.smtp_port || '587'),
secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
username: config.smtp_username,
password: config.smtp_password,
from_address: config.smtp_from_address || 'noreply@monacousa.org',
from_name: config.smtp_from_name || 'Monaco USA'
};
}
return {
host: config.smtp_host,
port: parseInt(config.smtp_port || '587'),
secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
username: config.smtp_username,
password: config.smtp_password,
from_address: config.smtp_from_address || 'noreply@monacousa.org',
from_name: config.smtp_from_name || 'Monaco USA'
};
// Fall back to environment variables (same as GoTrue SMTP settings)
const envHost = process.env.SMTP_HOST || process.env.GOTRUE_SMTP_HOST;
const envUser = process.env.SMTP_USER || process.env.GOTRUE_SMTP_USER;
const envPass = process.env.SMTP_PASS || process.env.GOTRUE_SMTP_PASS;
if (envHost && envUser && envPass) {
const envPort = process.env.SMTP_PORT || process.env.GOTRUE_SMTP_PORT || '587';
return {
host: envHost,
port: parseInt(envPort),
secure: parseInt(envPort) === 465,
username: envUser,
password: envPass,
from_address: process.env.SMTP_ADMIN_EMAIL || process.env.GOTRUE_SMTP_ADMIN_EMAIL || 'noreply@monacousa.org',
from_name: process.env.SMTP_SENDER_NAME || process.env.GOTRUE_SMTP_SENDER_NAME || 'Monaco USA'
};
}
return null;
}
/**

View File

@ -0,0 +1,139 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
export const load: PageServerLoad = async ({ locals }) => {
const { member } = await locals.safeGetSession();
if (!member || member.role !== 'admin') {
throw redirect(303, '/dashboard');
}
// Load recent notifications with member info
const { data: notifications } = await supabaseAdmin
.from('notifications')
.select(`
*,
member:members!member_id (
id,
first_name,
last_name,
email
)
`)
.order('created_at', { ascending: false })
.limit(100);
// Load all members for the recipient selector
const { data: members } = await supabaseAdmin
.from('members')
.select('id, first_name, last_name, email, role')
.order('last_name', { ascending: true });
return {
notifications: notifications || [],
members: members || []
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const { member } = await locals.safeGetSession();
if (!member || member.role !== 'admin') {
return fail(403, { error: 'Unauthorized' });
}
const formData = await request.formData();
const type = formData.get('type') as string;
const title = formData.get('title') as string;
const message = formData.get('message') as string;
const link = (formData.get('link') as string) || null;
const recipientType = formData.get('recipient_type') as string;
const recipientId = formData.get('recipient_id') as string;
if (!type || !title || !message) {
return fail(400, { error: 'Type, title, and message are required' });
}
// Validate type
const validTypes = ['welcome', 'event', 'payment', 'membership', 'system', 'announcement'];
if (!validTypes.includes(type)) {
return fail(400, { error: 'Invalid notification type' });
}
let memberIds: string[] = [];
if (recipientType === 'all') {
// Get all member IDs
const { data: allMembers } = await supabaseAdmin
.from('members')
.select('id');
memberIds = (allMembers || []).map(m => m.id);
} else if (recipientType === 'role') {
const role = formData.get('role') as string;
const { data: roleMembers } = await supabaseAdmin
.from('members')
.select('id')
.eq('role', role);
memberIds = (roleMembers || []).map(m => m.id);
} else if (recipientType === 'single' && recipientId) {
memberIds = [recipientId];
} else {
return fail(400, { error: 'Please select at least one recipient' });
}
if (memberIds.length === 0) {
return fail(400, { error: 'No recipients found' });
}
// Create notifications for all selected members
const notifications = memberIds.map(memberId => ({
member_id: memberId,
type,
title,
message,
link
}));
const { error } = await supabaseAdmin
.from('notifications')
.insert(notifications);
if (error) {
console.error('Create notification error:', error);
return fail(500, { error: 'Failed to create notification(s)' });
}
return {
success: `Notification sent to ${memberIds.length} member${memberIds.length === 1 ? '' : 's'}!`
};
},
delete: async ({ request, locals }) => {
const { member } = await locals.safeGetSession();
if (!member || member.role !== 'admin') {
return fail(403, { error: 'Unauthorized' });
}
const formData = await request.formData();
const id = formData.get('id') as string;
if (!id) {
return fail(400, { error: 'Notification ID is required' });
}
const { error } = await supabaseAdmin
.from('notifications')
.delete()
.eq('id', id);
if (error) {
console.error('Delete notification error:', error);
return fail(500, { error: 'Failed to delete notification' });
}
return { success: 'Notification deleted' };
}
};

View File

@ -0,0 +1,342 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Bell, Send, Trash2, Users, Calendar, CreditCard, Megaphone, Settings, ChevronDown, X, Check, AlertCircle } from 'lucide-svelte';
interface Member {
id: string;
first_name: string;
last_name: string;
email: string;
role: string;
}
interface Notification {
id: string;
type: string;
title: string;
message: string;
link: string | null;
read_at: string | null;
created_at: string;
member: Member | null;
}
let { data, form } = $props();
let recipientType = $state<'all' | 'role' | 'single'>('all');
let selectedRole = $state('member');
let selectedMemberId = $state('');
let showCreateForm = $state(false);
let submitting = $state(false);
const notificationTypes = [
{ value: 'announcement', label: 'Announcement', icon: Megaphone, color: 'text-monaco-600 bg-monaco-100' },
{ value: 'event', label: 'Event', icon: Calendar, color: 'text-blue-600 bg-blue-100' },
{ value: 'payment', label: 'Payment', icon: CreditCard, color: 'text-amber-600 bg-amber-100' },
{ value: 'membership', label: 'Membership', icon: Users, color: 'text-purple-600 bg-purple-100' },
{ value: 'system', label: 'System', icon: Settings, color: 'text-slate-600 bg-slate-100' }
];
function getTypeInfo(type: string) {
return notificationTypes.find(t => t.value === type) || notificationTypes[4];
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getRecipientCount() {
if (recipientType === 'all') return data.members.length;
if (recipientType === 'role') return data.members.filter((m: Member) => m.role === selectedRole).length;
if (recipientType === 'single' && selectedMemberId) return 1;
return 0;
}
</script>
<svelte:head>
<title>Notifications Management | Monaco USA Admin</title>
</svelte:head>
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Notification Management</h1>
<p class="mt-1 text-slate-600">Send notifications to members and view notification history</p>
</div>
<button
onclick={() => showCreateForm = !showCreateForm}
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 transition-colors"
>
{#if showCreateForm}
<X class="h-4 w-4" />
Cancel
{:else}
<Send class="h-4 w-4" />
Send Notification
{/if}
</button>
</div>
<!-- Success/Error Messages -->
{#if form?.success}
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-4 flex items-center gap-3">
<Check class="h-5 w-5 text-green-600" />
<p class="text-green-800">{form.success}</p>
</div>
{/if}
{#if form?.error}
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 flex items-center gap-3">
<AlertCircle class="h-5 w-5 text-red-600" />
<p class="text-red-800">{form.error}</p>
</div>
{/if}
<!-- Create Notification Form -->
{#if showCreateForm}
<div class="mb-8 rounded-xl border border-slate-200 bg-white p-6">
<h2 class="text-lg font-semibold text-slate-900 mb-6">Create New Notification</h2>
<form
method="POST"
action="?/create"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
if (form?.success) {
showCreateForm = false;
}
};
}}
class="space-y-6"
>
<!-- Notification Type -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">Notification Type</label>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
{#each notificationTypes as type}
{@const Icon = type.icon}
<label class="relative">
<input type="radio" name="type" value={type.value} class="peer sr-only" required />
<div class="flex flex-col items-center gap-2 rounded-lg border-2 border-slate-200 p-3 cursor-pointer transition-all peer-checked:border-monaco-600 peer-checked:bg-monaco-50 hover:border-slate-300">
<div class="rounded-full p-2 {type.color}">
<Icon class="h-4 w-4" />
</div>
<span class="text-sm font-medium text-slate-700">{type.label}</span>
</div>
</label>
{/each}
</div>
</div>
<!-- Title -->
<div>
<label for="title" class="block text-sm font-medium text-slate-700 mb-2">Title</label>
<input
type="text"
id="title"
name="title"
required
maxlength="100"
placeholder="e.g., New Event Announcement"
class="w-full rounded-lg border border-slate-300 px-4 py-2 focus:border-monaco-500 focus:ring-2 focus:ring-monaco-500/20 outline-none"
/>
</div>
<!-- Message -->
<div>
<label for="message" class="block text-sm font-medium text-slate-700 mb-2">Message</label>
<textarea
id="message"
name="message"
required
rows="3"
maxlength="500"
placeholder="Enter the notification message..."
class="w-full rounded-lg border border-slate-300 px-4 py-2 focus:border-monaco-500 focus:ring-2 focus:ring-monaco-500/20 outline-none resize-none"
></textarea>
</div>
<!-- Link (optional) -->
<div>
<label for="link" class="block text-sm font-medium text-slate-700 mb-2">
Link (optional)
</label>
<input
type="text"
id="link"
name="link"
placeholder="/events or /profile"
class="w-full rounded-lg border border-slate-300 px-4 py-2 focus:border-monaco-500 focus:ring-2 focus:ring-monaco-500/20 outline-none"
/>
<p class="mt-1 text-xs text-slate-500">Users will be redirected here when they click the notification</p>
</div>
<!-- Recipients -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">Recipients</label>
<div class="space-y-3">
<!-- All Members -->
<label class="flex items-center gap-3 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50 has-[:checked]:border-monaco-600 has-[:checked]:bg-monaco-50">
<input
type="radio"
name="recipient_type"
value="all"
bind:group={recipientType}
class="text-monaco-600 focus:ring-monaco-500"
/>
<div class="flex-1">
<span class="font-medium text-slate-900">All Members</span>
<span class="ml-2 text-sm text-slate-500">({data.members.length} members)</span>
</div>
</label>
<!-- By Role -->
<label class="flex items-center gap-3 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50 has-[:checked]:border-monaco-600 has-[:checked]:bg-monaco-50">
<input
type="radio"
name="recipient_type"
value="role"
bind:group={recipientType}
class="text-monaco-600 focus:ring-monaco-500"
/>
<div class="flex-1">
<span class="font-medium text-slate-900">By Role</span>
</div>
</label>
{#if recipientType === 'role'}
<div class="ml-8">
<select
name="role"
bind:value={selectedRole}
class="rounded-lg border border-slate-300 px-4 py-2 focus:border-monaco-500 focus:ring-2 focus:ring-monaco-500/20 outline-none"
>
<option value="member">Members ({data.members.filter((m: Member) => m.role === 'member').length})</option>
<option value="board">Board Members ({data.members.filter((m: Member) => m.role === 'board').length})</option>
<option value="admin">Admins ({data.members.filter((m: Member) => m.role === 'admin').length})</option>
</select>
</div>
{/if}
<!-- Single Member -->
<label class="flex items-center gap-3 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50 has-[:checked]:border-monaco-600 has-[:checked]:bg-monaco-50">
<input
type="radio"
name="recipient_type"
value="single"
bind:group={recipientType}
class="text-monaco-600 focus:ring-monaco-500"
/>
<div class="flex-1">
<span class="font-medium text-slate-900">Single Member</span>
</div>
</label>
{#if recipientType === 'single'}
<div class="ml-8">
<select
name="recipient_id"
bind:value={selectedMemberId}
required={recipientType === 'single'}
class="w-full rounded-lg border border-slate-300 px-4 py-2 focus:border-monaco-500 focus:ring-2 focus:ring-monaco-500/20 outline-none"
>
<option value="">Select a member...</option>
{#each data.members as m}
<option value={m.id}>{m.first_name} {m.last_name} ({m.email})</option>
{/each}
</select>
</div>
{/if}
</div>
</div>
<!-- Submit -->
<div class="flex items-center justify-between pt-4 border-t border-slate-100">
<p class="text-sm text-slate-600">
This will send a notification to <strong>{getRecipientCount()}</strong> member{getRecipientCount() === 1 ? '' : 's'}
</p>
<button
type="submit"
disabled={submitting || getRecipientCount() === 0}
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-6 py-2 font-medium text-white hover:bg-monaco-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if submitting}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
{:else}
<Send class="h-4 w-4" />
{/if}
Send Notification
</button>
</div>
</form>
</div>
{/if}
<!-- Recent Notifications -->
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-lg font-semibold text-slate-900">Recent Notifications</h2>
<p class="text-sm text-slate-500">Last 100 notifications sent</p>
</div>
{#if data.notifications.length === 0}
<div class="p-12 text-center">
<Bell class="mx-auto h-12 w-12 text-slate-300" />
<h3 class="mt-4 text-lg font-medium text-slate-900">No notifications yet</h3>
<p class="mt-2 text-slate-600">Create your first notification above</p>
</div>
{:else}
<div class="divide-y divide-slate-100">
{#each data.notifications as notification}
{@const typeInfo = getTypeInfo(notification.type)}
{@const Icon = typeInfo.icon}
<div class="flex items-start gap-4 px-6 py-4 hover:bg-slate-50">
<div class="flex-shrink-0 rounded-full p-2 {typeInfo.color}">
<Icon class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium text-slate-900">{notification.title}</p>
<p class="mt-0.5 text-sm text-slate-600 line-clamp-2">{notification.message}</p>
<div class="mt-2 flex items-center gap-3 text-xs text-slate-500">
<span>
To: {notification.member ? `${notification.member.first_name} ${notification.member.last_name}` : 'Unknown'}
</span>
<span>&bull;</span>
<span>{formatDate(notification.created_at)}</span>
{#if notification.read_at}
<span class="inline-flex items-center gap-1 text-green-600">
<Check class="h-3 w-3" />
Read
</span>
{:else}
<span class="text-amber-600">Unread</span>
{/if}
</div>
</div>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={notification.id} />
<button
type="submit"
class="rounded-lg p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
title="Delete notification"
>
<Trash2 class="h-4 w-4" />
</button>
</form>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { member } = await locals.safeGetSession();
if (!member) {
return { notifications: [] };
}
const { data: notifications } = await locals.supabase
.from('notifications')
.select('*')
.eq('member_id', member.id)
.order('created_at', { ascending: false });
return {
notifications: notifications || []
};
};

View File

@ -0,0 +1,221 @@
<script lang="ts">
import { Bell, Calendar, CreditCard, Users, Megaphone, Settings, Check, ChevronRight, Trash2 } from 'lucide-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 { data } = $props();
let notifications = $state<Notification[]>(data.notifications);
let filter = $state<'all' | 'unread'>('all');
const filteredNotifications = $derived(
filter === 'unread'
? notifications.filter(n => !n.read_at)
: notifications
);
const unreadCount = $derived(notifications.filter(n => !n.read_at).length);
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 getTypeBadge(type: Notification['type']) {
switch (type) {
case 'welcome': return { text: 'Welcome', class: 'bg-green-100 text-green-700' };
case 'event': return { text: 'Event', class: 'bg-blue-100 text-blue-700' };
case 'payment': return { text: 'Payment', class: 'bg-amber-100 text-amber-700' };
case 'membership': return { text: 'Membership', class: 'bg-purple-100 text-purple-700' };
case 'system': return { text: 'System', class: 'bg-slate-100 text-slate-700' };
case 'announcement': return { text: 'Announcement', class: 'bg-monaco-100 text-monaco-700' };
default: return { text: 'Other', class: 'bg-slate-100 text-slate-700' };
}
}
function formatDate(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} minute${diffMins === 1 ? '' : 's'} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
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 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 as read:', e);
}
}
async function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
await markAsRead(notification.id);
}
if (notification.link) {
goto(notification.link);
}
}
</script>
<svelte:head>
<title>Notifications | Monaco USA</title>
</svelte:head>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900">Notifications</h1>
<p class="mt-1 text-slate-600">
{#if unreadCount > 0}
You have {unreadCount} unread notification{unreadCount === 1 ? '' : 's'}
{:else}
All caught up!
{/if}
</p>
</div>
{#if unreadCount > 0}
<button
onclick={markAllAsRead}
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors"
>
<Check class="h-4 w-4" />
Mark all as read
</button>
{/if}
</div>
<!-- Filter tabs -->
<div class="mt-6 flex gap-2">
<button
onclick={() => filter = 'all'}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {filter === 'all' ? 'bg-monaco-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}"
>
All ({notifications.length})
</button>
<button
onclick={() => filter = 'unread'}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {filter === 'unread' ? 'bg-monaco-600 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}"
>
Unread ({unreadCount})
</button>
</div>
</div>
<!-- Notifications List -->
{#if filteredNotifications.length === 0}
<div class="rounded-xl border border-slate-200 bg-white p-12 text-center">
<Bell class="mx-auto h-12 w-12 text-slate-300" />
<h3 class="mt-4 text-lg font-medium text-slate-900">
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
</h3>
<p class="mt-2 text-slate-600">
{filter === 'unread' ? 'You\'re all caught up!' : 'When you receive notifications, they\'ll appear here.'}
</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredNotifications as notification}
{@const Icon = getTypeIcon(notification.type)}
{@const badge = getTypeBadge(notification.type)}
<button
onclick={() => handleNotificationClick(notification)}
class="w-full rounded-xl border bg-white p-4 text-left transition-all hover:shadow-md {notification.read_at ? 'border-slate-200' : 'border-monaco-200 bg-monaco-50/30'}"
>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
<Icon class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {badge.class}">
{badge.text}
</span>
{#if !notification.read_at}
<span class="h-2 w-2 rounded-full bg-monaco-600"></span>
{/if}
</div>
<h3 class="text-base font-semibold text-slate-900 {notification.read_at ? '' : 'text-slate-900'}">
{notification.title}
</h3>
<p class="mt-1 text-sm text-slate-600 line-clamp-2">
{notification.message}
</p>
<p class="mt-2 text-xs text-slate-400">
{formatDate(notification.created_at)}
</p>
</div>
{#if notification.link}
<ChevronRight class="h-5 w-5 flex-shrink-0 text-slate-400" />
{/if}
</div>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>