Add notifications pages and fix RLS/email issues
Build and Push Docker Image / build (push) Successful in 2m7s
Details
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:
parent
0053fa2b5e
commit
4e3cf89f62
|
|
@ -749,6 +749,9 @@ CREATE POLICY "Board can manage events"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
|
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
|
-- EVENT RSVPs POLICIES
|
||||||
|
|
@ -771,6 +774,9 @@ CREATE POLICY "Board can manage all RSVPs"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
|
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
|
-- PUBLIC RSVPs POLICIES
|
||||||
|
|
@ -791,6 +797,9 @@ CREATE POLICY "Board can manage public RSVPs"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
|
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
|
-- DOCUMENTS POLICIES
|
||||||
|
|
@ -821,6 +830,9 @@ CREATE POLICY "Admin can manage all documents"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
|
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
|
-- APP SETTINGS POLICIES
|
||||||
|
|
@ -840,6 +852,9 @@ CREATE POLICY "Admin can manage settings"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
|
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
|
-- EMAIL TEMPLATES POLICIES
|
||||||
|
|
@ -848,6 +863,9 @@ CREATE POLICY "Admin can manage email templates"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
|
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
|
-- EMAIL LOGS POLICIES
|
||||||
|
|
@ -864,6 +882,9 @@ CREATE POLICY "Admin can manage email logs"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
|
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
|
-- INDEXES
|
||||||
|
|
@ -1456,6 +1477,9 @@ CREATE POLICY "Admin can manage all notifications"
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (
|
USING (
|
||||||
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
|
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
|
-- Grant permissions
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
|
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -84,14 +85,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNotificationClick(notification: Notification) {
|
async function handleNotificationClick(notification: Notification) {
|
||||||
if (!notification.read_at) {
|
if (!notification.read_at) {
|
||||||
markAsRead(notification.id);
|
markAsRead(notification.id);
|
||||||
}
|
}
|
||||||
if (notification.link) {
|
|
||||||
window.location.href = notification.link;
|
|
||||||
}
|
|
||||||
closeDropdown();
|
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']) {
|
function getTypeIcon(type: Notification['type']) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
Shield,
|
Shield,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
CalendarPlus
|
CalendarPlus,
|
||||||
|
Bell
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import type { MemberWithDues } from '$lib/types/database';
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
|
|
||||||
const adminNav: NavItem[] = [
|
const adminNav: NavItem[] = [
|
||||||
{ href: '/admin/members', label: 'User Management', icon: Shield },
|
{ href: '/admin/members', label: 'User Management', icon: Shield },
|
||||||
|
{ href: '/admin/notifications', label: 'Notifications', icon: Bell },
|
||||||
{ href: '/admin/settings', label: 'Settings', icon: Settings }
|
{ href: '/admin/settings', label: 'Settings', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
export async function getSmtpConfig(): Promise<SmtpConfig | null> {
|
||||||
|
// First try to get from app_settings
|
||||||
const { data: settings } = await supabaseAdmin
|
const { data: settings } = await supabaseAdmin
|
||||||
.from('app_settings')
|
.from('app_settings')
|
||||||
.select('setting_key, setting_value')
|
.select('setting_key, setting_value')
|
||||||
.eq('category', 'email');
|
.eq('category', 'email');
|
||||||
|
|
||||||
if (!settings || settings.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Record<string, string> = {};
|
const config: Record<string, string> = {};
|
||||||
for (const s of settings) {
|
if (settings && settings.length > 0) {
|
||||||
// Parse the value - it might be JSON stringified or plain
|
for (const s of settings) {
|
||||||
let value = s.setting_value;
|
// Parse the value - it might be JSON stringified or plain
|
||||||
if (typeof value === 'string') {
|
let value = s.setting_value;
|
||||||
// Remove surrounding quotes if present
|
if (typeof value === 'string') {
|
||||||
value = value.replace(/^"|"$/g, '');
|
// 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
|
// Check if app_settings has valid SMTP config
|
||||||
if (!config.smtp_host || !config.smtp_username || !config.smtp_password) {
|
if (config.smtp_host && config.smtp_username && config.smtp_password) {
|
||||||
return null;
|
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 {
|
// Fall back to environment variables (same as GoTrue SMTP settings)
|
||||||
host: config.smtp_host,
|
const envHost = process.env.SMTP_HOST || process.env.GOTRUE_SMTP_HOST;
|
||||||
port: parseInt(config.smtp_port || '587'),
|
const envUser = process.env.SMTP_USER || process.env.GOTRUE_SMTP_USER;
|
||||||
secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
|
const envPass = process.env.SMTP_PASS || process.env.GOTRUE_SMTP_PASS;
|
||||||
username: config.smtp_username,
|
|
||||||
password: config.smtp_password,
|
if (envHost && envUser && envPass) {
|
||||||
from_address: config.smtp_from_address || 'noreply@monacousa.org',
|
const envPort = process.env.SMTP_PORT || process.env.GOTRUE_SMTP_PORT || '587';
|
||||||
from_name: config.smtp_from_name || 'Monaco USA'
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>•</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>
|
||||||
|
|
@ -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 || []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue