Initial production deployment setup
- Production docker-compose with nginx support - Nginx configuration for portal.monacousa.org - Deployment script with backup/restore - Gitea CI/CD workflow - Fix CountryFlag reactivity for dropdown flags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
179
src/routes/(app)/+error.svelte
Normal file
179
src/routes/(app)/+error.svelte
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
// Error details with friendly messages
|
||||
const errorMessages: Record<number, { title: string; message: string; icon: string; color: string }> = {
|
||||
400: {
|
||||
title: 'Bad Request',
|
||||
message: "Something went wrong with your request. Please check and try again.",
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
color: 'amber'
|
||||
},
|
||||
401: {
|
||||
title: 'Session Expired',
|
||||
message: 'Your session has expired. Please sign in again to continue.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
color: 'blue'
|
||||
},
|
||||
403: {
|
||||
title: 'Access Denied',
|
||||
message: "You don't have permission to access this resource. Contact an administrator if you believe this is an error.",
|
||||
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
||||
color: 'red'
|
||||
},
|
||||
404: {
|
||||
title: 'Page Not Found',
|
||||
message: "We couldn't find the page you're looking for. It may have been moved or deleted.",
|
||||
icon: 'M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
color: 'slate'
|
||||
},
|
||||
500: {
|
||||
title: 'Server Error',
|
||||
message: 'Something went wrong on our end. Our team has been notified. Please try again later.',
|
||||
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
color: 'red'
|
||||
}
|
||||
};
|
||||
|
||||
const status = $derived($page.status);
|
||||
const errorInfo = $derived(
|
||||
errorMessages[status] || {
|
||||
title: 'Something Went Wrong',
|
||||
message: $page.error?.message || 'An unexpected error occurred. Please try again.',
|
||||
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
color: 'slate'
|
||||
}
|
||||
);
|
||||
|
||||
const colorClasses = $derived({
|
||||
red: {
|
||||
bg: 'bg-gradient-to-br from-red-50 to-red-100',
|
||||
icon: 'text-red-500',
|
||||
border: 'border-red-200'
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-gradient-to-br from-amber-50 to-amber-100',
|
||||
icon: 'text-amber-500',
|
||||
border: 'border-amber-200'
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-gradient-to-br from-blue-50 to-blue-100',
|
||||
icon: 'text-blue-500',
|
||||
border: 'border-blue-200'
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-gradient-to-br from-slate-50 to-slate-100',
|
||||
icon: 'text-slate-500',
|
||||
border: 'border-slate-200'
|
||||
}
|
||||
}[errorInfo.color]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} - {errorInfo.title} | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
||||
<!-- Logo -->
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard" class="inline-block">
|
||||
<img
|
||||
src="/MONACOUSA-Flags_376x376.png"
|
||||
alt="Monaco USA"
|
||||
class="h-16 w-16 rounded-xl object-contain shadow-md"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Card -->
|
||||
<div
|
||||
class="w-full max-w-lg rounded-2xl border border-slate-200/60 bg-white/80 p-4 sm:p-6 md:p-8 text-center shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<!-- Error Icon -->
|
||||
<div class="mx-auto mb-4 sm:mb-6 flex h-16 w-16 sm:h-24 sm:w-24 items-center justify-center rounded-full {colorClasses.bg}">
|
||||
<svg
|
||||
class="h-8 w-8 sm:h-12 sm:w-12 {colorClasses.icon}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={errorInfo.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error Code Badge -->
|
||||
<div
|
||||
class="mx-auto mb-3 sm:mb-4 inline-flex items-center rounded-full border {colorClasses.border} bg-white px-3 sm:px-4 py-1 sm:py-1.5"
|
||||
>
|
||||
<span class="text-sm font-semibold text-slate-500">Error {status}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h1 class="mb-3 text-2xl font-bold text-slate-900">{errorInfo.title}</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="mb-8 text-slate-600">{errorInfo.message}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<Button variant="monaco" size="lg" href="/dashboard">
|
||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="lg" onclick={() => history.back()}>
|
||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
{#if status === 401}
|
||||
<Button variant="outline" size="lg" href="/login">
|
||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Sign In
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="mt-8 text-sm text-slate-500">
|
||||
Need assistance?
|
||||
<a href="mailto:support@monacousa.org" class="text-monaco-600 hover:text-monaco-700">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Technical Details (collapsed by default) -->
|
||||
{#if $page.error?.message && import.meta.env.DEV}
|
||||
<details class="mt-6 w-full max-w-lg">
|
||||
<summary class="cursor-pointer text-sm text-slate-400 hover:text-slate-600">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-2 overflow-auto rounded-lg bg-slate-100 p-4 text-xs text-slate-600">{$page.error.message}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
27
src/routes/(app)/+layout.server.ts
Normal file
27
src/routes/(app)/+layout.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
const { session, user, member } = await locals.safeGetSession();
|
||||
|
||||
// Require authentication for all app routes
|
||||
if (!session) {
|
||||
throw redirect(303, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||
}
|
||||
|
||||
// Require member profile to exist
|
||||
if (!member) {
|
||||
// User is authenticated but has no member profile - unusual situation
|
||||
await locals.supabase.auth.signOut();
|
||||
throw redirect(303, '/login?error=no_profile');
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
const emailVerified = user?.email_confirmed_at !== null && user?.email_confirmed_at !== undefined;
|
||||
|
||||
return {
|
||||
session,
|
||||
member,
|
||||
emailVerified
|
||||
};
|
||||
};
|
||||
87
src/routes/(app)/+layout.svelte
Normal file
87
src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Sidebar, Header, MobileNav, MobileMenu } from '$lib/components/layout';
|
||||
import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
let sidebarCollapsed = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
// Get page title from route data or default
|
||||
const pageTitle = $derived(getPageTitle($page.url.pathname));
|
||||
|
||||
function getPageTitle(path: string): string {
|
||||
const titles: Record<string, string> = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/profile': 'My Profile',
|
||||
'/events': 'Events',
|
||||
'/payments': 'Payments',
|
||||
'/documents': 'Documents',
|
||||
'/board/members': 'Members',
|
||||
'/board/dues': 'Dues Management',
|
||||
'/board/events': 'Manage Events',
|
||||
'/admin/users': 'User Management',
|
||||
'/admin/settings': 'Settings'
|
||||
};
|
||||
|
||||
for (const [route, title] of Object.entries(titles)) {
|
||||
if (path.startsWith(route)) return title;
|
||||
}
|
||||
|
||||
return 'Monaco USA';
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
}
|
||||
|
||||
function openMobileMenu() {
|
||||
mobileMenuOpen = true;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||
<!-- Desktop Sidebar -->
|
||||
<div class="hidden lg:block">
|
||||
<Sidebar
|
||||
member={data.member}
|
||||
currentPath={$page.url.pathname}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<Header member={data.member} title={pageTitle} onMenuToggle={openMobileMenu} />
|
||||
|
||||
<!-- Email Verification Banner -->
|
||||
{#if !data.emailVerified}
|
||||
<EmailVerificationBanner email={data.member.email} />
|
||||
{/if}
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 overflow-y-auto pb-20 lg:pb-0">
|
||||
<div class="container mx-auto max-w-7xl p-4 lg:p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
<MobileNav member={data.member} currentPath={$page.url.pathname} onMenuOpen={openMobileMenu} />
|
||||
|
||||
<!-- Mobile Menu Overlay -->
|
||||
<MobileMenu
|
||||
member={data.member}
|
||||
currentPath={$page.url.pathname}
|
||||
open={mobileMenuOpen}
|
||||
onClose={closeMobileMenu}
|
||||
/>
|
||||
</div>
|
||||
13
src/routes/(app)/admin/+layout.server.ts
Normal file
13
src/routes/(app)/admin/+layout.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Only admins can access admin pages
|
||||
if (member?.role !== 'admin') {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
91
src/routes/(app)/admin/dashboard/+page.server.ts
Normal file
91
src/routes/(app)/admin/dashboard/+page.server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getRecentAuditLogs } from '$lib/server/audit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Get all members with dues info for stats
|
||||
const { data: members } = await locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('*');
|
||||
|
||||
// Get recent payments (this month and last month)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
|
||||
const { data: recentPayments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select(`
|
||||
*,
|
||||
member:members(first_name, last_name, email)
|
||||
`)
|
||||
.gte('payment_date', startOfLastMonth.toISOString())
|
||||
.order('payment_date', { ascending: false });
|
||||
|
||||
// Get upcoming events (next 30 days)
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
const { data: upcomingEvents } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('status', 'published')
|
||||
.gte('start_datetime', now.toISOString())
|
||||
.lte('start_datetime', thirtyDaysFromNow.toISOString())
|
||||
.order('start_datetime', { ascending: true })
|
||||
.limit(5);
|
||||
|
||||
// Get recent audit logs
|
||||
const { logs: auditLogs } = await getRecentAuditLogs(20);
|
||||
|
||||
// Calculate member stats
|
||||
const memberStats = {
|
||||
total: members?.length || 0,
|
||||
byRole: {
|
||||
admin: members?.filter(m => m.role === 'admin').length || 0,
|
||||
board: members?.filter(m => m.role === 'board').length || 0,
|
||||
member: members?.filter(m => m.role === 'member').length || 0
|
||||
},
|
||||
byDuesStatus: {
|
||||
current: members?.filter(m => m.dues_status === 'current').length || 0,
|
||||
due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0,
|
||||
overdue: members?.filter(m => m.dues_status === 'overdue').length || 0,
|
||||
never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate revenue stats
|
||||
const thisMonthPayments = recentPayments?.filter(p =>
|
||||
new Date(p.payment_date) >= startOfMonth
|
||||
) || [];
|
||||
const lastMonthPayments = recentPayments?.filter(p =>
|
||||
new Date(p.payment_date) >= startOfLastMonth &&
|
||||
new Date(p.payment_date) < startOfMonth
|
||||
) || [];
|
||||
|
||||
const revenueStats = {
|
||||
thisMonth: thisMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0),
|
||||
lastMonth: lastMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0),
|
||||
thisMonthCount: thisMonthPayments.length,
|
||||
lastMonthCount: lastMonthPayments.length
|
||||
};
|
||||
|
||||
// Get recent payments for display
|
||||
const { data: latestPayments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select(`
|
||||
*,
|
||||
member:members(first_name, last_name, email),
|
||||
recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
return {
|
||||
memberStats,
|
||||
revenueStats,
|
||||
upcomingEvents: upcomingEvents || [],
|
||||
recentPayments: latestPayments || [],
|
||||
auditLogs: auditLogs || [],
|
||||
overdueMembers: members?.filter(m => m.dues_status === 'overdue').slice(0, 5) || []
|
||||
};
|
||||
};
|
||||
295
src/routes/(app)/admin/dashboard/+page.svelte
Normal file
295
src/routes/(app)/admin/dashboard/+page.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Users,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Shield,
|
||||
Activity,
|
||||
FileText
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const { memberStats, revenueStats, upcomingEvents, recentPayments, auditLogs, overdueMembers } = data;
|
||||
|
||||
// Calculate revenue trend
|
||||
const revenueTrend = revenueStats.lastMonth > 0
|
||||
? ((revenueStats.thisMonth - revenueStats.lastMonth) / revenueStats.lastMonth * 100).toFixed(1)
|
||||
: 0;
|
||||
const revenueUp = Number(revenueTrend) >= 0;
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatAuditAction(action: string): string {
|
||||
const actionMap: Record<string, string> = {
|
||||
'member.create': 'Created member',
|
||||
'member.update': 'Updated member',
|
||||
'member.delete': 'Deleted member',
|
||||
'member.role_change': 'Changed role',
|
||||
'member.status_change': 'Changed status',
|
||||
'member.invite': 'Invited member',
|
||||
'event.create': 'Created event',
|
||||
'event.update': 'Updated event',
|
||||
'event.delete': 'Deleted event',
|
||||
'payment.record': 'Recorded payment',
|
||||
'document.upload': 'Uploaded document',
|
||||
'document.delete': 'Deleted document',
|
||||
'settings.update': 'Updated settings'
|
||||
};
|
||||
return actionMap[action] || action;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin Dashboard | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Admin Dashboard</h1>
|
||||
<p class="text-slate-500">Overview of Monaco USA portal activity and metrics</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total Members -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Total Members</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{memberStats.total}</p>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||
<Users class="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2 text-xs">
|
||||
<span class="rounded bg-purple-100 px-2 py-0.5 text-purple-700">{memberStats.byRole.admin} Admin</span>
|
||||
<span class="rounded bg-indigo-100 px-2 py-0.5 text-indigo-700">{memberStats.byRole.board} Board</span>
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-slate-700">{memberStats.byRole.member} Members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue This Month -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Revenue This Month</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{formatCurrency(revenueStats.thisMonth)}</p>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<DollarSign class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-1 text-sm">
|
||||
{#if revenueUp}
|
||||
<TrendingUp class="h-4 w-4 text-green-600" />
|
||||
<span class="text-green-600">+{revenueTrend}%</span>
|
||||
{:else}
|
||||
<TrendingDown class="h-4 w-4 text-red-600" />
|
||||
<span class="text-red-600">{revenueTrend}%</span>
|
||||
{/if}
|
||||
<span class="text-slate-500">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dues Status -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Dues Status</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{memberStats.byDuesStatus.current}</p>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||
<AlertCircle class="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2 text-xs">
|
||||
<span class="rounded bg-green-100 px-2 py-0.5 text-green-700">Current</span>
|
||||
<span class="rounded bg-amber-100 px-2 py-0.5 text-amber-700">{memberStats.byDuesStatus.due_soon} Due Soon</span>
|
||||
<span class="rounded bg-red-100 px-2 py-0.5 text-red-700">{memberStats.byDuesStatus.overdue} Overdue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Upcoming Events</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{upcomingEvents.length}</p>
|
||||
</div>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-monaco-100">
|
||||
<Calendar class="h-6 w-6 text-monaco-600" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-slate-500">Next 30 days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Recent Payments -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-slate-900">Recent Payments</h2>
|
||||
<a href="/board/dues" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||
</div>
|
||||
|
||||
{#if recentPayments.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each recentPayments as payment}
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/50 p-3">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{payment.member?.first_name} {payment.member?.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">{formatDate(payment.payment_date)}</p>
|
||||
</div>
|
||||
<span class="font-semibold text-green-600">{formatCurrency(payment.amount)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-slate-500">No recent payments</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-slate-900">Upcoming Events</h2>
|
||||
<a href="/board/events" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||
</div>
|
||||
|
||||
{#if upcomingEvents.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each upcomingEvents as event}
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50/50 p-3">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{event.title}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{formatDateTime(event.start_datetime)}
|
||||
{#if event.location}
|
||||
| {event.location}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-slate-900">{event.total_attendees}</span>
|
||||
{#if event.max_attendees}
|
||||
<span class="text-slate-500">/{event.max_attendees}</span>
|
||||
{/if}
|
||||
<p class="text-xs text-slate-500">attendees</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-slate-500">No upcoming events</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Overdue Members -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-slate-900">Overdue Dues</h2>
|
||||
<a href="/board/dues?status=overdue" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||
</div>
|
||||
|
||||
{#if overdueMembers.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each overdueMembers as member}
|
||||
<div class="flex items-center justify-between rounded-lg bg-red-50/50 p-3">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{member.first_name} {member.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">{member.email}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-medium text-red-600">
|
||||
{member.days_overdue} days
|
||||
</span>
|
||||
<p class="text-xs text-slate-500">overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-slate-500">
|
||||
<Shield class="mb-2 h-8 w-8" />
|
||||
<p>All members are current!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity (Audit Log) -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-slate-900">Recent Activity</h2>
|
||||
<Activity class="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
|
||||
{#if auditLogs.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each auditLogs.slice(0, 8) as log}
|
||||
<div class="flex items-start gap-3 text-sm">
|
||||
<div class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-slate-100">
|
||||
{#if log.action.startsWith('member')}
|
||||
<Users class="h-3 w-3 text-slate-500" />
|
||||
{:else if log.action.startsWith('event')}
|
||||
<Calendar class="h-3 w-3 text-slate-500" />
|
||||
{:else if log.action.startsWith('payment')}
|
||||
<DollarSign class="h-3 w-3 text-slate-500" />
|
||||
{:else if log.action.startsWith('document')}
|
||||
<FileText class="h-3 w-3 text-slate-500" />
|
||||
{:else}
|
||||
<Clock class="h-3 w-3 text-slate-500" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-slate-900 truncate">
|
||||
{formatAuditAction(log.action)}
|
||||
{#if log.details?.target_email}
|
||||
<span class="text-slate-500">({log.details.target_email})</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{log.user_email || 'System'} · {formatDateTime(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-slate-500">No recent activity</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
83
src/routes/(app)/admin/email-templates/+page.server.ts
Normal file
83
src/routes/(app)/admin/email-templates/+page.server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Check admin access
|
||||
const { data: { user } } = await locals.supabase.auth.getUser();
|
||||
if (!user) throw redirect(303, '/login');
|
||||
|
||||
const { data: member } = await locals.supabase
|
||||
.from('members')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!member || !['admin', 'board'].includes(member.role)) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
// Load all email templates
|
||||
const { data: templates, error } = await locals.supabase
|
||||
.from('email_templates')
|
||||
.select('*')
|
||||
.order('category', { ascending: true })
|
||||
.order('template_name', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading email templates:', error);
|
||||
return { templates: [] };
|
||||
}
|
||||
|
||||
return { templates: templates || [] };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateTemplate: async ({ request, locals }) => {
|
||||
const { data: { user } } = await locals.supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return fail(401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check admin access
|
||||
const { data: member } = await locals.supabase
|
||||
.from('members')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!member || !['admin', 'board'].includes(member.role)) {
|
||||
return fail(403, { error: 'Access denied' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const template_key = formData.get('template_key') as string;
|
||||
const subject = formData.get('subject') as string;
|
||||
const body_html = formData.get('body_html') as string;
|
||||
const body_text = formData.get('body_text') as string;
|
||||
const is_active = formData.get('is_active') === 'true';
|
||||
|
||||
if (!template_key) {
|
||||
return fail(400, { error: 'Template key is required' });
|
||||
}
|
||||
|
||||
// Update the template
|
||||
const { error } = await locals.supabase
|
||||
.from('email_templates')
|
||||
.update({
|
||||
subject,
|
||||
body_html,
|
||||
body_text,
|
||||
is_active,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: user.id
|
||||
})
|
||||
.eq('template_key', template_key);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating template:', error);
|
||||
return fail(500, { error: 'Failed to update template' });
|
||||
}
|
||||
|
||||
return { success: true, message: 'Template updated successfully' };
|
||||
}
|
||||
};
|
||||
566
src/routes/(app)/admin/email-templates/+page.svelte
Normal file
566
src/routes/(app)/admin/email-templates/+page.svelte
Normal file
@@ -0,0 +1,566 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
Mail,
|
||||
X,
|
||||
Save,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
Search,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
Variable
|
||||
} from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
interface EmailTemplate {
|
||||
template_key: string;
|
||||
template_name: string;
|
||||
category: string;
|
||||
subject: string;
|
||||
body_html: string;
|
||||
body_text: string;
|
||||
is_active: boolean;
|
||||
is_system: boolean;
|
||||
variables_schema: Record<string, string> | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
let { data, form } = $props();
|
||||
const templates = $derived(data.templates as EmailTemplate[]);
|
||||
|
||||
// UI State
|
||||
let selectedCategory = $state<string>('all');
|
||||
let searchQuery = $state('');
|
||||
let editingTemplate = $state<EmailTemplate | null>(null);
|
||||
let showPreview = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Edit form state
|
||||
let editSubject = $state('');
|
||||
let editBodyHtml = $state('');
|
||||
let editBodyText = $state('');
|
||||
let editIsActive = $state(true);
|
||||
|
||||
// Track which field is active for variable insertion
|
||||
let activeField = $state<'subject' | 'body_html' | 'body_text'>('subject');
|
||||
|
||||
// Element refs
|
||||
let subjectInput: HTMLInputElement;
|
||||
let bodyHtmlTextarea: HTMLTextAreaElement;
|
||||
let bodyTextTextarea: HTMLTextAreaElement;
|
||||
|
||||
// Sample data for preview
|
||||
const sampleData: Record<string, string> = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
member_id: 'MUSA-0001',
|
||||
portal_url: 'https://portal.monacousa.org',
|
||||
amount: '$150.00',
|
||||
due_date: 'February 15, 2026',
|
||||
event_title: 'Monaco National Day Celebration',
|
||||
event_date: 'November 19, 2026',
|
||||
event_time: '6:00 PM',
|
||||
event_location: 'The Monaco Club, New York',
|
||||
guest_count: '2',
|
||||
reset_link: 'https://portal.monacousa.org/reset-password?token=abc123',
|
||||
verification_link: 'https://portal.monacousa.org/verify?token=abc123'
|
||||
};
|
||||
|
||||
// Get unique categories
|
||||
const categories = $derived.by(() => {
|
||||
const cats = new Set(templates.map(t => t.category));
|
||||
return ['all', ...Array.from(cats).sort()];
|
||||
});
|
||||
|
||||
// Group templates by category
|
||||
const templatesByCategory = $derived.by(() => {
|
||||
return templates.reduce((acc, t) => {
|
||||
const cat = t.category || 'other';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(t);
|
||||
return acc;
|
||||
}, {} as Record<string, EmailTemplate[]>);
|
||||
});
|
||||
|
||||
// Filtered templates
|
||||
const filteredTemplates = $derived.by(() => {
|
||||
let result = templates;
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
result = result.filter(t => t.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(t =>
|
||||
t.template_name.toLowerCase().includes(query) ||
|
||||
t.template_key.toLowerCase().includes(query) ||
|
||||
t.subject.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Get variables for the current template
|
||||
const templateVariables = $derived.by(() => {
|
||||
if (!editingTemplate?.variables_schema) return [];
|
||||
return Object.entries(editingTemplate.variables_schema);
|
||||
});
|
||||
|
||||
// Format category name
|
||||
function formatCategory(cat: string): string {
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// Open edit modal
|
||||
function openEditor(template: EmailTemplate) {
|
||||
editingTemplate = template;
|
||||
editSubject = template.subject;
|
||||
editBodyHtml = template.body_html;
|
||||
editBodyText = template.body_text;
|
||||
editIsActive = template.is_active;
|
||||
showPreview = false;
|
||||
activeField = 'subject';
|
||||
}
|
||||
|
||||
// Close edit modal
|
||||
function closeEditor() {
|
||||
editingTemplate = null;
|
||||
showPreview = false;
|
||||
}
|
||||
|
||||
// Insert variable at cursor position
|
||||
function insertVariable(varName: string) {
|
||||
const tag = `{{${varName}}}`;
|
||||
|
||||
if (activeField === 'subject' && subjectInput) {
|
||||
const start = subjectInput.selectionStart ?? editSubject.length;
|
||||
const end = subjectInput.selectionEnd ?? editSubject.length;
|
||||
editSubject = editSubject.slice(0, start) + tag + editSubject.slice(end);
|
||||
// Restore focus and cursor position
|
||||
setTimeout(() => {
|
||||
subjectInput.focus();
|
||||
subjectInput.setSelectionRange(start + tag.length, start + tag.length);
|
||||
}, 0);
|
||||
} else if (activeField === 'body_html' && bodyHtmlTextarea) {
|
||||
const start = bodyHtmlTextarea.selectionStart ?? editBodyHtml.length;
|
||||
const end = bodyHtmlTextarea.selectionEnd ?? editBodyHtml.length;
|
||||
editBodyHtml = editBodyHtml.slice(0, start) + tag + editBodyHtml.slice(end);
|
||||
setTimeout(() => {
|
||||
bodyHtmlTextarea.focus();
|
||||
bodyHtmlTextarea.setSelectionRange(start + tag.length, start + tag.length);
|
||||
}, 0);
|
||||
} else if (activeField === 'body_text' && bodyTextTextarea) {
|
||||
const start = bodyTextTextarea.selectionStart ?? editBodyText.length;
|
||||
const end = bodyTextTextarea.selectionEnd ?? editBodyText.length;
|
||||
editBodyText = editBodyText.slice(0, start) + tag + editBodyText.slice(end);
|
||||
setTimeout(() => {
|
||||
bodyTextTextarea.focus();
|
||||
bodyTextTextarea.setSelectionRange(start + tag.length, start + tag.length);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Render preview with sample data (content only)
|
||||
function renderPreview(html: string): string {
|
||||
let result = html;
|
||||
for (const [key, value] of Object.entries(sampleData)) {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||
}
|
||||
// Also replace any remaining variables with placeholder
|
||||
result = result.replace(/\{\{(\w+)\}\}/g, '<span style="color: #d97706; background: #fef3c7; padding: 2px 4px; border-radius: 4px;">[[$1]]</span>');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate full email HTML preview
|
||||
// If template already has full HTML wrapper (legacy templates), render directly
|
||||
// Otherwise wrap with Monaco template
|
||||
function generateFullEmailPreview(subject: string, bodyHtml: string): string {
|
||||
const renderedBody = renderPreview(bodyHtml);
|
||||
|
||||
// Check if template already has full HTML wrapper (legacy templates)
|
||||
const hasFullWrapper = bodyHtml.includes('<!DOCTYPE') || bodyHtml.includes('<html');
|
||||
|
||||
if (hasFullWrapper) {
|
||||
// Template has its own HTML structure - render as-is with variables replaced
|
||||
// Also inject background image URL for S3
|
||||
let html = renderedBody;
|
||||
// Update background image URL if using old gradient-only style
|
||||
html = html.replace(
|
||||
/background:\s*linear-gradient\([^)]+\);\s*background-color:\s*#0f172a;/g,
|
||||
"background-image: url('https://s3.monacousa.org/public/monaco_high_res.jpg'); background-size: cover; background-position: center; background-color: #0f172a;"
|
||||
);
|
||||
// Fix logo URL for preview
|
||||
html = html.replace(/\{\{logo_url\}\}/g, '/MONACOUSA-Flags_376x376.png');
|
||||
return html;
|
||||
}
|
||||
|
||||
// Content-only template - wrap with Monaco template
|
||||
const renderedSubject = renderPreview(subject);
|
||||
const logoUrl = '/MONACOUSA-Flags_376x376.png';
|
||||
const bgImageUrl = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
|
||||
const emailTitle = renderedSubject.replace(/<[^>]*>/g, '').trim() || 'Email Preview';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${bgImageUrl}'); background-size: cover; background-position: center; background-color: #0f172a;">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.8) 0%, rgba(15, 23, 42, 0.6) 50%, rgba(127, 29, 29, 0.7) 100%);">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Logo Section -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom: 30px;">
|
||||
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||
</div>
|
||||
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px; text-align: center;">${emailTitle}</h2>
|
||||
<div style="text-align: left;">${renderedBody}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 24px;">
|
||||
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Get preview HTML as data URL for iframe
|
||||
const previewDataUrl = $derived.by(() => {
|
||||
if (!showPreview || !editingTemplate) return '';
|
||||
const html = generateFullEmailPreview(editSubject, editBodyHtml);
|
||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
|
||||
});
|
||||
|
||||
// Get category color
|
||||
function getCategoryColor(cat: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
auth: 'bg-blue-100 text-blue-700',
|
||||
payment: 'bg-green-100 text-green-700',
|
||||
events: 'bg-purple-100 text-purple-700',
|
||||
membership: 'bg-amber-100 text-amber-700',
|
||||
other: 'bg-slate-100 text-slate-700'
|
||||
};
|
||||
return colors[cat] || colors.other;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email Templates | Monaco USA Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/admin/settings"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-monaco-600"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Email Templates</h1>
|
||||
<p class="text-slate-500">Edit the text content of email notifications sent by the system</p>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
{#if form?.success}
|
||||
<div class="flex items-center gap-2 rounded-lg bg-green-50 border border-green-200 p-4 text-green-700">
|
||||
<CheckCircle class="h-5 w-5 shrink-0" />
|
||||
<span>{form.message || 'Template updated successfully'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-4 text-red-700">
|
||||
<XCircle class="h-5 w-5 shrink-0" />
|
||||
<span>{form.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<!-- Category Filter -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="category" class="text-sm font-medium text-slate-700">Category:</Label>
|
||||
<select
|
||||
id="category"
|
||||
bind:value={selectedCategory}
|
||||
class="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||
>
|
||||
{#each categories as cat}
|
||||
<option value={cat}>{cat === 'all' ? 'All Categories' : formatCategory(cat)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1 sm:max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search templates..."
|
||||
bind:value={searchQuery}
|
||||
class="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredTemplates as template}
|
||||
<div class="glass-card p-5 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {getCategoryColor(template.category)}">
|
||||
{formatCategory(template.category)}
|
||||
</span>
|
||||
{#if !template.is_active}
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500">
|
||||
Inactive
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="font-semibold text-slate-900 truncate">{template.template_name}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500 truncate">{template.subject}</p>
|
||||
</div>
|
||||
<Mail class="h-5 w-5 shrink-0 text-slate-400" />
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-slate-400">{template.template_key}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openEditor(template)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="col-span-full glass-card p-12 text-center">
|
||||
<FileText class="mx-auto h-12 w-12 text-slate-300" />
|
||||
<p class="mt-4 text-slate-500">No templates found</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if editingTemplate}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="my-8 w-full max-w-4xl rounded-2xl bg-white shadow-2xl">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-slate-900">Edit: {editingTemplate.template_name}</h2>
|
||||
<p class="text-sm text-slate-500">{editingTemplate.template_key}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={closeEditor}
|
||||
class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateTemplate"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update, result }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
closeEditor();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="p-6 space-y-6"
|
||||
>
|
||||
<input type="hidden" name="template_key" value={editingTemplate.template_key} />
|
||||
|
||||
<!-- Subject Line -->
|
||||
<div class="space-y-2">
|
||||
<Label for="subject">Subject Line</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
bind:value={editSubject}
|
||||
bind:this={subjectInput}
|
||||
onfocus={() => activeField = 'subject'}
|
||||
class="h-11 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Variables Panel -->
|
||||
{#if templateVariables.length > 0}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Variable class="h-4 w-4 text-slate-600" />
|
||||
<span class="text-sm font-medium text-slate-700">Available Variables</span>
|
||||
<span class="text-xs text-slate-500">(click to insert at cursor)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each templateVariables as [varName, description]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => insertVariable(varName)}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-mono text-slate-700 hover:bg-monaco-50 hover:border-monaco-300 hover:text-monaco-700 transition-colors"
|
||||
title={description}
|
||||
>
|
||||
{`{{${varName}}}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Body HTML -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="body_html">Email Body (HTML)</Label>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => showPreview = !showPreview}
|
||||
class="inline-flex items-center gap-1.5 text-sm text-monaco-600 hover:text-monaco-700"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
{showPreview ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showPreview}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-900 overflow-hidden" style="min-height: 500px;">
|
||||
<div class="bg-slate-800 px-4 py-2 border-b border-slate-700">
|
||||
<p class="text-xs text-slate-400">Subject: <span class="text-white font-medium">{renderPreview(editSubject)}</span></p>
|
||||
</div>
|
||||
<iframe
|
||||
src={previewDataUrl}
|
||||
title="Email Preview"
|
||||
class="w-full border-0"
|
||||
style="height: 600px;"
|
||||
sandbox="allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
id="body_html"
|
||||
name="body_html"
|
||||
bind:value={editBodyHtml}
|
||||
bind:this={bodyHtmlTextarea}
|
||||
onfocus={() => activeField = 'body_html'}
|
||||
rows="12"
|
||||
class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm text-slate-900 focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20 resize-y"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Body Text -->
|
||||
<div class="space-y-2">
|
||||
<Label for="body_text">Plain Text Version (fallback)</Label>
|
||||
<textarea
|
||||
id="body_text"
|
||||
name="body_text"
|
||||
bind:value={editBodyText}
|
||||
bind:this={bodyTextTextarea}
|
||||
onfocus={() => activeField = 'body_text'}
|
||||
rows="6"
|
||||
class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm text-slate-900 focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20 resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Active Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={editIsActive}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<input type="hidden" name="is_active" value={editIsActive ? 'true' : 'false'} />
|
||||
<div class="peer h-6 w-11 rounded-full bg-slate-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-monaco-600 peer-checked:after:translate-x-5"></div>
|
||||
</label>
|
||||
<span class="text-sm text-slate-700">Template Active</span>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button type="button" variant="outline" onclick={closeEditor} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="monaco" disabled={isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
425
src/routes/(app)/admin/email-testing/+page.server.ts
Normal file
425
src/routes/(app)/admin/email-testing/+page.server.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
import { sendEmail, sendTemplatedEmail, getSmtpConfig, wrapInMonacoTemplate } from '$lib/server/email';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return {
|
||||
templates: [],
|
||||
recentLogs: [],
|
||||
smtpConfigured: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if SMTP is configured
|
||||
const smtpConfig = await getSmtpConfig();
|
||||
const smtpConfigured = !!smtpConfig;
|
||||
|
||||
// Fetch all email templates
|
||||
const { data: templates } = await supabaseAdmin
|
||||
.from('email_templates')
|
||||
.select('*')
|
||||
.order('category', { ascending: true })
|
||||
.order('name', { ascending: true });
|
||||
|
||||
// Fetch recent email logs
|
||||
const { data: recentLogs } = await supabaseAdmin
|
||||
.from('email_logs')
|
||||
.select(`
|
||||
*,
|
||||
sender:members!email_logs_sent_by_fkey(first_name, last_name)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
// Group templates by category
|
||||
const templatesByCategory: Record<string, typeof templates> = {};
|
||||
for (const template of templates || []) {
|
||||
const category = template.category || 'other';
|
||||
if (!templatesByCategory[category]) {
|
||||
templatesByCategory[category] = [];
|
||||
}
|
||||
templatesByCategory[category].push(template);
|
||||
}
|
||||
|
||||
return {
|
||||
templates: templates || [],
|
||||
templatesByCategory,
|
||||
recentLogs: recentLogs || [],
|
||||
smtpConfigured,
|
||||
adminEmail: member.email
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
/**
|
||||
* Send a test email using a template
|
||||
*/
|
||||
sendTestTemplate: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can send test emails' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const templateKey = formData.get('template_key') as string;
|
||||
const recipientEmail = formData.get('recipient_email') as string;
|
||||
|
||||
if (!templateKey || !recipientEmail) {
|
||||
return fail(400, { error: 'Template and recipient email are required' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(recipientEmail)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
// Build test variables based on template type
|
||||
const testVariables = getTestVariables(templateKey, member, url.origin);
|
||||
|
||||
const result = await sendTemplatedEmail(templateKey, recipientEmail, testVariables, {
|
||||
recipientId: member.id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`,
|
||||
sentBy: member.id,
|
||||
baseUrl: url.origin
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to send test email' });
|
||||
}
|
||||
|
||||
return { success: `Test email sent successfully to ${recipientEmail}!` };
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom test email
|
||||
*/
|
||||
sendCustomEmail: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can send test emails' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const recipientEmail = formData.get('recipient_email') as string;
|
||||
const subject = formData.get('subject') as string;
|
||||
const messageContent = formData.get('message') as string;
|
||||
|
||||
if (!recipientEmail || !subject || !messageContent) {
|
||||
return fail(400, { error: 'Recipient, subject, and message are required' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(recipientEmail)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
// Wrap the message in the Monaco template
|
||||
const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
|
||||
const html = wrapInMonacoTemplate({
|
||||
title: subject,
|
||||
content: `<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">${messageContent.replace(/\n/g, '<br>')}</p>`,
|
||||
logoUrl
|
||||
});
|
||||
|
||||
const result = await sendEmail({
|
||||
to: recipientEmail,
|
||||
subject,
|
||||
html,
|
||||
emailType: 'test_custom',
|
||||
sentBy: member.id
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to send email' });
|
||||
}
|
||||
|
||||
return { success: `Custom email sent successfully to ${recipientEmail}!` };
|
||||
},
|
||||
|
||||
/**
|
||||
* Test all notification types to a single recipient
|
||||
*/
|
||||
sendAllTests: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can send test emails' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const recipientEmail = formData.get('recipient_email') as string;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return fail(400, { error: 'Recipient email is required' });
|
||||
}
|
||||
|
||||
// Get all active templates
|
||||
const { data: templates } = await supabaseAdmin
|
||||
.from('email_templates')
|
||||
.select('template_key')
|
||||
.eq('is_active', true);
|
||||
|
||||
if (!templates || templates.length === 0) {
|
||||
return fail(400, { error: 'No active email templates found' });
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const template of templates) {
|
||||
const testVariables = getTestVariables(template.template_key, member, url.origin);
|
||||
|
||||
const result = await sendTemplatedEmail(template.template_key, recipientEmail, testVariables, {
|
||||
recipientId: member.id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`,
|
||||
sentBy: member.id,
|
||||
baseUrl: url.origin
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
errors.push(`${template.template_key}: ${result.error}`);
|
||||
}
|
||||
|
||||
// Small delay between emails to avoid rate limiting
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
return { success: `All ${successCount} test emails sent successfully!` };
|
||||
} else if (successCount === 0) {
|
||||
return fail(500, { error: `All emails failed to send. Errors: ${errors.join('; ')}` });
|
||||
} else {
|
||||
return {
|
||||
success: `Sent ${successCount} emails, ${failCount} failed.`,
|
||||
errors
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Preview a template (returns HTML)
|
||||
*/
|
||||
previewTemplate: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const templateKey = formData.get('template_key') as string;
|
||||
|
||||
if (!templateKey) {
|
||||
return fail(400, { error: 'Template key is required' });
|
||||
}
|
||||
|
||||
// Fetch the template
|
||||
const { data: template } = await supabaseAdmin
|
||||
.from('email_templates')
|
||||
.select('*')
|
||||
.eq('template_key', templateKey)
|
||||
.single();
|
||||
|
||||
if (!template) {
|
||||
return fail(404, { error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Get test variables
|
||||
const testVariables = getTestVariables(templateKey, member, url.origin);
|
||||
const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
|
||||
|
||||
// Replace variables in the template
|
||||
let html = template.body_html;
|
||||
let subject = template.subject;
|
||||
|
||||
const allVariables: Record<string, string> = {
|
||||
logo_url: logoUrl,
|
||||
site_url: url.origin,
|
||||
...testVariables
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(allVariables)) {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
html = html.replace(regex, value);
|
||||
subject = subject.replace(regex, value);
|
||||
}
|
||||
|
||||
return {
|
||||
preview: {
|
||||
subject,
|
||||
html,
|
||||
templateName: template.name
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get test variables for different template types
|
||||
*/
|
||||
function getTestVariables(templateKey: string, member: any, baseUrl: string): Record<string, string> {
|
||||
const commonVars = {
|
||||
first_name: member.first_name || 'Test',
|
||||
last_name: member.last_name || 'User',
|
||||
member_name: `${member.first_name || 'Test'} ${member.last_name || 'User'}`,
|
||||
member_id: member.member_id || 'TEST-001',
|
||||
email: member.email || 'test@example.com',
|
||||
site_url: baseUrl,
|
||||
portal_url: baseUrl,
|
||||
logo_url: `${baseUrl}/MONACOUSA-Flags_376x376.png`
|
||||
};
|
||||
|
||||
// Template-specific variables
|
||||
switch (templateKey) {
|
||||
case 'welcome':
|
||||
return {
|
||||
...commonVars,
|
||||
login_url: `${baseUrl}/login`
|
||||
};
|
||||
|
||||
case 'payment_received':
|
||||
return {
|
||||
...commonVars,
|
||||
amount: '100.00',
|
||||
payment_date: new Date().toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
reference: 'TEST-REF-123',
|
||||
due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
};
|
||||
|
||||
case 'dues_reminder_30':
|
||||
case 'dues_reminder_7':
|
||||
case 'dues_reminder_1':
|
||||
const daysMap: Record<string, string> = {
|
||||
dues_reminder_30: '30',
|
||||
dues_reminder_7: '7',
|
||||
dues_reminder_1: '1'
|
||||
};
|
||||
return {
|
||||
...commonVars,
|
||||
days_until_due: daysMap[templateKey] || '30',
|
||||
due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
amount_due: '100.00',
|
||||
payment_url: `${baseUrl}/payments`,
|
||||
bank_name: 'Monaco Bank',
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
bic: 'MONACOXX',
|
||||
payment_reference: `DUES-${member.member_id || 'TEST001'}`
|
||||
};
|
||||
|
||||
case 'dues_overdue':
|
||||
return {
|
||||
...commonVars,
|
||||
days_overdue: '15',
|
||||
due_date: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
amount_due: '100.00',
|
||||
payment_url: `${baseUrl}/payments`,
|
||||
bank_name: 'Monaco Bank',
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
bic: 'MONACOXX',
|
||||
payment_reference: `DUES-${member.member_id || 'TEST001'}`
|
||||
};
|
||||
|
||||
case 'dues_grace_warning':
|
||||
return {
|
||||
...commonVars,
|
||||
grace_days_remaining: '7',
|
||||
grace_end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
amount_due: '100.00',
|
||||
payment_url: `${baseUrl}/payments`
|
||||
};
|
||||
|
||||
case 'dues_inactive_notice':
|
||||
return {
|
||||
...commonVars,
|
||||
inactive_date: new Date().toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
reactivation_url: `${baseUrl}/payments`
|
||||
};
|
||||
|
||||
case 'event_invitation':
|
||||
return {
|
||||
...commonVars,
|
||||
event_title: 'Monaco USA Annual Gala',
|
||||
event_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
event_time: '7:00 PM',
|
||||
event_location: 'Hotel de Paris, Monaco',
|
||||
event_description: 'Join us for our annual celebration bringing together Americans living in Monaco.',
|
||||
event_url: `${baseUrl}/events/test-event`,
|
||||
rsvp_url: `${baseUrl}/events/test-event`
|
||||
};
|
||||
|
||||
case 'event_reminder':
|
||||
return {
|
||||
...commonVars,
|
||||
event_title: 'Monaco USA Monthly Meetup',
|
||||
event_date: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
event_time: '6:00 PM',
|
||||
event_location: 'Stars n Bars, Monaco',
|
||||
event_url: `${baseUrl}/events/test-event`
|
||||
};
|
||||
|
||||
case 'waitlist_promotion':
|
||||
return {
|
||||
...commonVars,
|
||||
event_title: 'Exclusive Wine Tasting Event',
|
||||
event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
event_time: '8:00 PM',
|
||||
event_location: 'Cave Princesse, Monaco',
|
||||
event_url: `${baseUrl}/events/test-event`,
|
||||
confirm_url: `${baseUrl}/events/test-event`
|
||||
};
|
||||
|
||||
default:
|
||||
return commonVars;
|
||||
}
|
||||
}
|
||||
561
src/routes/(app)/admin/email-testing/+page.svelte
Normal file
561
src/routes/(app)/admin/email-testing/+page.svelte
Normal file
@@ -0,0 +1,561 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
X,
|
||||
RefreshCw,
|
||||
Inbox,
|
||||
Settings
|
||||
} from 'lucide-svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const templates = $derived(data.templates);
|
||||
const templatesByCategory = $derived(data.templatesByCategory);
|
||||
const recentLogs = $derived(data.recentLogs);
|
||||
const smtpConfigured = $derived(data.smtpConfigured);
|
||||
const adminEmail = $derived(data.adminEmail);
|
||||
|
||||
let selectedTemplate = $state<string>('');
|
||||
let recipientEmail = $state(adminEmail || '');
|
||||
let customSubject = $state('Test Email from Monaco USA');
|
||||
let customMessage = $state('This is a test email sent from the Monaco USA admin panel.');
|
||||
let isLoading = $state(false);
|
||||
let activeTab = $state<'templates' | 'custom' | 'logs'>('templates');
|
||||
|
||||
// Preview modal state
|
||||
let showPreview = $state(false);
|
||||
let previewHtml = $state('');
|
||||
let previewSubject = $state('');
|
||||
let isLoadingPreview = $state(false);
|
||||
|
||||
// Get category display name
|
||||
function getCategoryName(category: string): string {
|
||||
const names: Record<string, string> = {
|
||||
dues: 'Dues & Payments',
|
||||
events: 'Events',
|
||||
auth: 'Authentication',
|
||||
welcome: 'Welcome & Onboarding',
|
||||
notifications: 'Notifications',
|
||||
other: 'Other'
|
||||
};
|
||||
return names[category] || category.charAt(0).toUpperCase() + category.slice(1);
|
||||
}
|
||||
|
||||
// Get status badge styling
|
||||
function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
return { color: 'bg-green-100 text-green-700', icon: CheckCircle2 };
|
||||
case 'failed':
|
||||
return { color: 'bg-red-100 text-red-700', icon: XCircle };
|
||||
case 'pending':
|
||||
return { color: 'bg-yellow-100 text-yellow-700', icon: Clock };
|
||||
default:
|
||||
return { color: 'bg-slate-100 text-slate-700', icon: Mail };
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Email Testing | Monaco USA Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Email Testing</h1>
|
||||
<p class="text-slate-500">Test email notifications across the platform</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
class="inline-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"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
Email Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Status Banner -->
|
||||
{#if !smtpConfigured}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 class="font-medium text-amber-800">SMTP Not Configured</h3>
|
||||
<p class="mt-1 text-sm text-amber-700">
|
||||
Email sending is not configured. Please configure SMTP settings in the
|
||||
<a href="/admin/settings" class="underline hover:text-amber-900">Admin Settings</a>
|
||||
before testing emails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<div class="flex items-center gap-2 text-green-800">
|
||||
<CheckCircle2 class="h-5 w-5" />
|
||||
<span class="font-medium">{form.success}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div class="flex items-center gap-2 text-red-800">
|
||||
<XCircle class="h-5 w-5" />
|
||||
<span class="font-medium">{form.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 border-b border-slate-200">
|
||||
<button
|
||||
onclick={() => (activeTab = 'templates')}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'templates'
|
||||
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||
: 'text-slate-600 hover:text-slate-900'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<FileText class="h-4 w-4" />
|
||||
Email Templates
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'custom')}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'custom'
|
||||
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||
: 'text-slate-600 hover:text-slate-900'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Mail class="h-4 w-4" />
|
||||
Custom Email
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'logs')}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'logs'
|
||||
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||
: 'text-slate-600 hover:text-slate-900'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Inbox class="h-4 w-4" />
|
||||
Recent Logs ({recentLogs.length})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Templates Tab -->
|
||||
{#if activeTab === 'templates'}
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Template List -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
{#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<h3 class="font-semibold text-slate-900">{getCategoryName(category)}</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each categoryTemplates as template}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 hover:bg-slate-50 cursor-pointer {selectedTemplate === template.template_key ? 'bg-monaco-50 border-l-4 border-monaco-600' : ''}"
|
||||
onclick={() => (selectedTemplate = template.template_key)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (selectedTemplate = template.template_key)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-slate-900">{template.name}</p>
|
||||
<p class="text-sm text-slate-500 truncate">{template.subject}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if template.is_active}
|
||||
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||
Inactive
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !templates || templates.length === 0}
|
||||
<div class="glass-card p-12 text-center">
|
||||
<FileText class="mx-auto h-12 w-12 text-slate-300" />
|
||||
<h3 class="mt-4 text-lg font-medium text-slate-900">No Email Templates</h3>
|
||||
<p class="mt-2 text-slate-500">
|
||||
No email templates have been configured yet.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Send Test Panel -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="glass-card p-6 sticky top-6">
|
||||
<h3 class="font-semibold text-slate-900 mb-4">Send Test Email</h3>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/sendTestTemplate"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label for="recipient_email">Recipient Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="recipient_email"
|
||||
name="recipient_email"
|
||||
bind:value={recipientEmail}
|
||||
placeholder="test@example.com"
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="template_key">Selected Template</Label>
|
||||
<select
|
||||
id="template_key"
|
||||
name="template_key"
|
||||
bind:value={selectedTemplate}
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
>
|
||||
<option value="">Select a template...</option>
|
||||
{#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
|
||||
<optgroup label={getCategoryName(category)}>
|
||||
{#each categoryTemplates as template}
|
||||
<option value={template.template_key}>{template.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="monaco"
|
||||
disabled={isLoading || !smtpConfigured || !selectedTemplate}
|
||||
class="flex-1"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Send Test
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Preview Button -->
|
||||
{#if selectedTemplate}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/previewTemplate"
|
||||
use:enhance={() => {
|
||||
isLoadingPreview = true;
|
||||
return async ({ result, update }) => {
|
||||
isLoadingPreview = false;
|
||||
if (result.type === 'success' && result.data?.preview) {
|
||||
previewHtml = result.data.preview.html;
|
||||
previewSubject = result.data.preview.subject;
|
||||
showPreview = true;
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
<input type="hidden" name="template_key" value={selectedTemplate} />
|
||||
<Button type="submit" variant="outline" disabled={isLoadingPreview} class="w-full">
|
||||
{#if isLoadingPreview}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
{:else}
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
Preview Template
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Send All Tests -->
|
||||
<div class="mt-6 pt-6 border-t border-slate-200">
|
||||
<h4 class="text-sm font-medium text-slate-700 mb-3">Bulk Testing</h4>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/sendAllTests"
|
||||
use:enhance={() => {
|
||||
if (!confirm(`This will send ALL active email templates to ${recipientEmail}. Continue?`)) {
|
||||
return async () => {};
|
||||
}
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="recipient_email" value={recipientEmail} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={isLoading || !smtpConfigured || !recipientEmail}
|
||||
class="w-full border-amber-300 text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
Send All Templates
|
||||
</Button>
|
||||
</form>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
Sends all active templates to test the complete notification system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Custom Email Tab -->
|
||||
{#if activeTab === 'custom'}
|
||||
<div class="max-w-2xl">
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="font-semibold text-slate-900 mb-4">Send Custom Test Email</h3>
|
||||
<p class="text-sm text-slate-500 mb-6">
|
||||
Send a custom email using the Monaco USA branding template.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/sendCustomEmail"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label for="custom_recipient">Recipient Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="custom_recipient"
|
||||
name="recipient_email"
|
||||
bind:value={recipientEmail}
|
||||
placeholder="test@example.com"
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="custom_subject">Subject</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="custom_subject"
|
||||
name="subject"
|
||||
bind:value={customSubject}
|
||||
placeholder="Email subject"
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="custom_message">Message</Label>
|
||||
<textarea
|
||||
id="custom_message"
|
||||
name="message"
|
||||
bind:value={customMessage}
|
||||
rows="6"
|
||||
placeholder="Your message here..."
|
||||
required
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
The message will be wrapped in the Monaco USA email template.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="monaco" disabled={isLoading || !smtpConfigured}>
|
||||
{#if isLoading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Send Custom Email
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Logs Tab -->
|
||||
{#if activeTab === 'logs'}
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-4 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-slate-900">Recent Email Logs</h3>
|
||||
<button
|
||||
onclick={() => invalidateAll()}
|
||||
class="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if recentLogs.length === 0}
|
||||
<div class="p-12 text-center">
|
||||
<Inbox class="mx-auto h-12 w-12 text-slate-300" />
|
||||
<h3 class="mt-4 text-lg font-medium text-slate-900">No Email Logs</h3>
|
||||
<p class="mt-2 text-slate-500">No emails have been sent yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Recipient</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Subject</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Sent</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each recentLogs as log}
|
||||
{@const statusBadge = getStatusBadge(log.status)}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {statusBadge.color}">
|
||||
<svelte:component this={statusBadge.icon} class="h-3 w-3" />
|
||||
{log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="text-sm text-slate-900">{log.recipient_email}</p>
|
||||
{#if log.recipient_name}
|
||||
<p class="text-xs text-slate-500">{log.recipient_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="text-sm text-slate-900 max-w-xs truncate" title={log.subject}>
|
||||
{log.subject}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
||||
{log.email_type || 'manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{log.sent_at ? formatDate(log.sent_at) : formatDate(log.created_at)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{#if log.sender}
|
||||
{log.sender.first_name} {log.sender.last_name}
|
||||
{:else}
|
||||
System
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{#if log.status === 'failed' && log.error_message}
|
||||
<tr class="bg-red-50">
|
||||
<td colspan="6" class="px-6 py-2">
|
||||
<p class="text-xs text-red-600">
|
||||
<strong>Error:</strong> {log.error_message}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
{#if showPreview}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-4xl max-h-[90vh] flex flex-col rounded-2xl bg-white shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">Email Preview</h3>
|
||||
<p class="text-sm text-slate-500">{previewSubject}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showPreview = false)}
|
||||
class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4 bg-slate-100">
|
||||
<div class="mx-auto max-w-lg">
|
||||
{@html previewHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-slate-200 px-6 py-4">
|
||||
<Button variant="outline" onclick={() => (showPreview = false)}>
|
||||
Close Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
430
src/routes/(app)/admin/members/+page.server.ts
Normal file
430
src/routes/(app)/admin/members/+page.server.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
import { sendEmail } from '$lib/server/email';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const searchQuery = url.searchParams.get('search') || '';
|
||||
const roleFilter = url.searchParams.get('role') || 'all';
|
||||
const statusFilter = url.searchParams.get('status') || 'all';
|
||||
|
||||
// Load all members with dues info using admin client (bypasses RLS for admin page)
|
||||
const { data: members } = await supabaseAdmin
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Filter members
|
||||
let filteredMembers = members || [];
|
||||
|
||||
if (searchQuery) {
|
||||
const lowerSearch = searchQuery.toLowerCase();
|
||||
filteredMembers = filteredMembers.filter(
|
||||
(m: any) =>
|
||||
m.first_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.last_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.email?.toLowerCase().includes(lowerSearch) ||
|
||||
m.member_id?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (roleFilter !== 'all') {
|
||||
filteredMembers = filteredMembers.filter((m: any) => m.role === roleFilter);
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter);
|
||||
}
|
||||
|
||||
// Load membership statuses for dropdown
|
||||
const { data: statuses } = await supabaseAdmin
|
||||
.from('membership_statuses')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: members?.length || 0,
|
||||
admins: members?.filter((m: any) => m.role === 'admin').length || 0,
|
||||
board: members?.filter((m: any) => m.role === 'board').length || 0,
|
||||
members: members?.filter((m: any) => m.role === 'member').length || 0
|
||||
};
|
||||
|
||||
return {
|
||||
members: filteredMembers,
|
||||
statuses: statuses || [],
|
||||
stats,
|
||||
filters: {
|
||||
search: searchQuery,
|
||||
role: roleFilter,
|
||||
status: statusFilter
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateRole: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
const newRole = formData.get('role') as string;
|
||||
|
||||
if (!memberId || !newRole) {
|
||||
return fail(400, { error: 'Member ID and role are required' });
|
||||
}
|
||||
|
||||
if (!['member', 'board', 'admin'].includes(newRole)) {
|
||||
return fail(400, { error: 'Invalid role' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('members')
|
||||
.update({ role: newRole, updated_at: new Date().toISOString() })
|
||||
.eq('id', memberId);
|
||||
|
||||
if (error) {
|
||||
console.error('Update role error:', error);
|
||||
return fail(500, { error: 'Failed to update role' });
|
||||
}
|
||||
|
||||
return { success: 'Role updated successfully!' };
|
||||
},
|
||||
|
||||
updateStatus: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
const statusId = formData.get('status_id') as string;
|
||||
|
||||
if (!memberId || !statusId) {
|
||||
return fail(400, { error: 'Member ID and status are required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('members')
|
||||
.update({
|
||||
membership_status_id: statusId,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', memberId);
|
||||
|
||||
if (error) {
|
||||
console.error('Update status error:', error);
|
||||
return fail(500, { error: 'Failed to update status' });
|
||||
}
|
||||
|
||||
return { success: 'Status updated successfully!' };
|
||||
},
|
||||
|
||||
deleteMember: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can delete members' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
|
||||
if (!memberId) {
|
||||
return fail(400, { error: 'Member ID is required' });
|
||||
}
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (memberId === member.id) {
|
||||
return fail(400, { error: 'You cannot delete your own account' });
|
||||
}
|
||||
|
||||
// First handle related records that have foreign keys to members
|
||||
|
||||
// Reassign events created by this member to the current admin
|
||||
await supabaseAdmin
|
||||
.from('events')
|
||||
.update({ created_by: member.id })
|
||||
.eq('created_by', memberId);
|
||||
|
||||
// Reassign app_settings updated by this member to the current admin
|
||||
await supabaseAdmin
|
||||
.from('app_settings')
|
||||
.update({ updated_by: member.id })
|
||||
.eq('updated_by', memberId);
|
||||
|
||||
// Delete dues payments
|
||||
await supabaseAdmin.from('dues_payments').delete().eq('member_id', memberId);
|
||||
|
||||
// Delete event RSVPs
|
||||
await supabaseAdmin.from('event_rsvps').delete().eq('member_id', memberId);
|
||||
|
||||
// Delete email logs
|
||||
await supabaseAdmin.from('email_logs').delete().eq('recipient_id', memberId);
|
||||
await supabaseAdmin.from('email_logs').delete().eq('sent_by', memberId);
|
||||
|
||||
// Now delete from members table using admin client (bypasses RLS)
|
||||
const { error } = await supabaseAdmin.from('members').delete().eq('id', memberId);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete member error:', error);
|
||||
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||
return fail(500, { error: `Failed to delete member: ${error.message}` });
|
||||
}
|
||||
|
||||
// Also delete the auth user using admin client
|
||||
const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(memberId);
|
||||
|
||||
if (authError) {
|
||||
console.error('Delete auth user error:', authError);
|
||||
// Member is already deleted, just log this
|
||||
}
|
||||
|
||||
return { success: 'Member deleted successfully!' };
|
||||
},
|
||||
|
||||
inviteMember: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can invite members' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
||||
const firstName = (formData.get('first_name') as string)?.trim() || '';
|
||||
const lastName = (formData.get('last_name') as string)?.trim() || '';
|
||||
const role = (formData.get('role') as string) || 'member';
|
||||
const duesPaidDate = formData.get('dues_paid_date') as string;
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { error: 'Email is required' });
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if (!['member', 'board', 'admin'].includes(role)) {
|
||||
return fail(400, { error: 'Invalid role' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const { data: existingMember } = await locals.supabase
|
||||
.from('members')
|
||||
.select('id')
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
if (existingMember) {
|
||||
return fail(400, { error: 'A member with this email already exists' });
|
||||
}
|
||||
|
||||
// Get default status (pending)
|
||||
const { data: defaultStatus } = await locals.supabase
|
||||
.from('membership_statuses')
|
||||
.select('id')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// Get default membership type
|
||||
const { data: defaultType } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('id, annual_dues')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// Create auth user with a temporary password using admin client (requires service_role)
|
||||
// The user will reset their password when they first log in
|
||||
const tempPassword = crypto.randomUUID();
|
||||
|
||||
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
|
||||
email,
|
||||
password: tempPassword,
|
||||
email_confirm: true, // Auto-confirm the email since admin is inviting
|
||||
user_metadata: {
|
||||
first_name: firstName || 'New',
|
||||
last_name: lastName || 'Member',
|
||||
invited_by: member.id
|
||||
}
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
console.error('Auth user creation error:', authError);
|
||||
if (authError.message.includes('already registered')) {
|
||||
return fail(400, { error: 'This email is already registered' });
|
||||
}
|
||||
return fail(500, { error: 'Failed to create user account. Please try again.' });
|
||||
}
|
||||
|
||||
if (!authData.user) {
|
||||
return fail(500, { error: 'Failed to create user account' });
|
||||
}
|
||||
|
||||
// Get active status if dues are paid
|
||||
let statusId = defaultStatus?.id;
|
||||
if (duesPaidDate) {
|
||||
const { data: activeStatus } = await locals.supabase
|
||||
.from('membership_statuses')
|
||||
.select('id')
|
||||
.eq('name', 'active')
|
||||
.single();
|
||||
if (activeStatus) {
|
||||
statusId = activeStatus.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Create member record
|
||||
const { error: memberError } = await locals.supabase.from('members').insert({
|
||||
id: authData.user.id,
|
||||
first_name: firstName || 'New',
|
||||
last_name: lastName || 'Member',
|
||||
email: email,
|
||||
phone: '',
|
||||
date_of_birth: '1990-01-01', // Placeholder - member will update
|
||||
address: 'TBD', // Placeholder
|
||||
nationality: [],
|
||||
role: role,
|
||||
membership_status_id: statusId,
|
||||
membership_type_id: defaultType?.id
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
console.error('Member creation error:', memberError);
|
||||
// Clean up auth user using admin client
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
return fail(500, { error: 'Failed to create member record. Please try again.' });
|
||||
}
|
||||
|
||||
// Create dues payment record if dues paid date is provided
|
||||
// The dues_paid_date is the date when dues are NEXT due (not when paid)
|
||||
// So we calculate payment_date as 1 year before the due date
|
||||
if (duesPaidDate) {
|
||||
const dueDate = new Date(duesPaidDate);
|
||||
const paymentDate = new Date(dueDate);
|
||||
paymentDate.setFullYear(paymentDate.getFullYear() - 1);
|
||||
|
||||
const { error: duesError } = await locals.supabase.from('dues_payments').insert({
|
||||
member_id: authData.user.id,
|
||||
amount: defaultType?.annual_dues || 0,
|
||||
payment_date: paymentDate.toISOString().split('T')[0],
|
||||
due_date: duesPaidDate,
|
||||
payment_method: 'other',
|
||||
notes: 'Initial dues set by admin during member invitation',
|
||||
recorded_by: member.id
|
||||
});
|
||||
|
||||
if (duesError) {
|
||||
console.error('Dues payment creation error:', duesError);
|
||||
// Non-critical - member still created
|
||||
}
|
||||
}
|
||||
|
||||
// Send welcome email with Monaco branding
|
||||
const baseUrl = url.origin;
|
||||
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
||||
const memberFirstName = firstName || 'New Member';
|
||||
|
||||
// Format dues paid date for display
|
||||
const formattedDuesPaidDate = duesPaidDate
|
||||
? new Date(duesPaidDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
: null;
|
||||
|
||||
// Dues section for the email
|
||||
const duesSection = duesPaidDate
|
||||
? `<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||
<p style="margin: 0 0 8px 0; color: #166534; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Membership Status</p>
|
||||
<p style="margin: 0 0 8px 0; color: #334155;"><strong>Status:</strong> Active Member</p>
|
||||
<p style="margin: 0; color: #334155;"><strong>Dues Paid Through:</strong> ${formattedDuesPaidDate}</p>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const welcomeEmailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: `Welcome to Monaco USA, ${memberFirstName}!`,
|
||||
html: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(30, 41, 59, 0.85) 50%, rgba(127, 29, 29, 0.8) 100%); background-color: #0f172a;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Logo Section -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom: 30px;">
|
||||
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||
</div>
|
||||
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px;">Welcome to Monaco USA!</h2>
|
||||
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">Dear ${memberFirstName},</p>
|
||||
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">We are thrilled to welcome you to the Monaco USA community! Your membership account has been created and you are now part of our growing network of Americans in Monaco.</p>
|
||||
${duesSection}
|
||||
<div style="background: #f8fafc; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||
<p style="margin: 0 0 12px 0; color: #334155; font-weight: 600;">To get started:</p>
|
||||
<ol style="margin: 0; padding: 0 0 0 20px; color: #334155; line-height: 1.8;">
|
||||
<li>You will receive a separate email shortly to set up your password</li>
|
||||
<li>Log in to your member portal at <a href="${baseUrl}" style="color: #CE1126; text-decoration: none;">${baseUrl}</a></li>
|
||||
<li>Complete your profile with your details</li>
|
||||
<li>Explore upcoming events and connect with fellow members</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p style="margin: 0 0 20px 0; color: #334155; line-height: 1.6;">If you have any questions, please don't hesitate to reach out to our board members.</p>
|
||||
<p style="margin: 0; color: #334155;">Best regards,<br><strong style="color: #CE1126;">The Monaco USA Team</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 24px;">
|
||||
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
recipientId: authData.user.id,
|
||||
recipientName: `${firstName} ${lastName}`.trim() || 'New Member',
|
||||
emailType: 'welcome',
|
||||
sentBy: member.id
|
||||
});
|
||||
|
||||
if (!welcomeEmailResult.success) {
|
||||
console.error('Welcome email error:', welcomeEmailResult.error);
|
||||
// Non-critical - member still created
|
||||
}
|
||||
|
||||
// Send password reset email so user can set their own password
|
||||
const { error: resetError } = await locals.supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${url.origin}/auth/reset-password`
|
||||
});
|
||||
|
||||
if (resetError) {
|
||||
console.error('Password reset email error:', resetError);
|
||||
// Member created but email failed - not critical
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Invitation sent to ${email}! They will receive a welcome email and instructions to set up their password.`
|
||||
};
|
||||
}
|
||||
};
|
||||
596
src/routes/(app)/admin/members/+page.svelte
Normal file
596
src/routes/(app)/admin/members/+page.svelte
Normal file
@@ -0,0 +1,596 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
UserCircle,
|
||||
Mail,
|
||||
Phone,
|
||||
Calendar,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
UserPlus,
|
||||
Send
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import CountryFlag from '$lib/components/ui/CountryFlag.svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
// Use $derived to make these reactive when data updates after invalidateAll()
|
||||
const members = $derived(data.members);
|
||||
const statuses = $derived(data.statuses);
|
||||
const stats = $derived(data.stats);
|
||||
const filters = $derived(data.filters);
|
||||
|
||||
let searchQuery = $state(filters.search);
|
||||
let roleFilter = $state(filters.role);
|
||||
let statusFilter = $state(filters.status);
|
||||
let memberToDelete = $state<any>(null);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let showInviteModal = $state(false);
|
||||
let inviteLoading = $state(false);
|
||||
let inviteEmail = $state('');
|
||||
let inviteFirstName = $state('');
|
||||
let inviteLastName = $state('');
|
||||
let inviteRole = $state('member');
|
||||
|
||||
// Default dues paid date to one year from today
|
||||
function getDefaultDuesPaidDate(): string {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
let inviteDuesPaidDate = $state('');
|
||||
|
||||
// Open invite modal with default date
|
||||
function openInviteModal() {
|
||||
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||
showInviteModal = true;
|
||||
}
|
||||
|
||||
// Close invite modal and reset form on success
|
||||
$effect(() => {
|
||||
if (form?.success && form.success.includes('Invitation sent')) {
|
||||
showInviteModal = false;
|
||||
inviteEmail = '';
|
||||
inviteFirstName = '';
|
||||
inviteLastName = '';
|
||||
inviteRole = 'member';
|
||||
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||
}
|
||||
});
|
||||
|
||||
// Close delete modal on success
|
||||
$effect(() => {
|
||||
if (form?.success && form.success.includes('deleted')) {
|
||||
showDeleteConfirm = false;
|
||||
memberToDelete = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Debounce search
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function handleSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
updateFilters({ search: value });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: Record<string, string>) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(newFilters)) {
|
||||
if (value && value !== 'all') {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
goto(`?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
function confirmDelete(member: any) {
|
||||
memberToDelete = member;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Get role info
|
||||
function getRoleInfo(role: string) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return { icon: ShieldCheck, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Admin' };
|
||||
case 'board':
|
||||
return { icon: Shield, color: 'text-blue-600', bg: 'bg-blue-100', label: 'Board' };
|
||||
default:
|
||||
return { icon: UserCircle, color: 'text-slate-600', bg: 'bg-slate-100', label: 'Member' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get status info
|
||||
function getStatusInfo(status: string | null) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100' };
|
||||
case 'pending':
|
||||
return { icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-100' };
|
||||
case 'inactive':
|
||||
return { icon: XCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||
case 'expired':
|
||||
return { icon: AlertTriangle, color: 'text-red-600', bg: 'bg-red-100' };
|
||||
default:
|
||||
return { icon: UserCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>User Management | Monaco USA Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">User Management</h1>
|
||||
<p class="text-slate-500">Manage member accounts, roles, and statuses</p>
|
||||
</div>
|
||||
<Button variant="monaco" onclick={openInviteModal}>
|
||||
<UserPlus class="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||
{form.success}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<Users class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
|
||||
<p class="text-xs text-slate-500">Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2">
|
||||
<ShieldCheck class="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.admins}</p>
|
||||
<p class="text-xs text-slate-500">Admins</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2">
|
||||
<Shield class="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.board}</p>
|
||||
<p class="text-xs text-slate-500">Board Members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<UserCircle class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.members}</p>
|
||||
<p class="text-xs text-slate-500">Members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or member ID..."
|
||||
value={searchQuery}
|
||||
oninput={(e) => {
|
||||
searchQuery = e.currentTarget.value;
|
||||
handleSearch(e.currentTarget.value);
|
||||
}}
|
||||
class="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
bind:value={roleFilter}
|
||||
onchange={() => updateFilters({ role: roleFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="member">Member</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={() => updateFilters({ status: statusFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
{#each statuses as status}
|
||||
<option value={status.name}>{status.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
{#if members.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Users class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No users found</h3>
|
||||
<p class="mt-1 text-slate-500">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">User</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Contact</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Joined</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each members as member}
|
||||
{@const roleInfo = getRoleInfo(member.role)}
|
||||
{@const statusInfo = getStatusInfo(member.status_name)}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if member.avatar_url}
|
||||
<img
|
||||
src={member.avatar_url}
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
|
||||
>
|
||||
{member.first_name?.[0]}{member.last_name?.[0]}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-slate-900">
|
||||
{member.first_name} {member.last_name}
|
||||
</p>
|
||||
{#if member.nationality && member.nationality.length > 0}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each member.nationality as code}
|
||||
<CountryFlag {code} size="xs" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">{member.member_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<Mail class="h-3.5 w-3.5" />
|
||||
{member.email}
|
||||
</div>
|
||||
{#if member.phone}
|
||||
<div class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<Phone class="h-3.5 w-3.5" />
|
||||
{member.phone}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateRole"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="member_id" value={member.id} />
|
||||
<select
|
||||
name="role"
|
||||
value={member.role}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm font-medium {roleInfo.color} cursor-pointer hover:bg-slate-100"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateStatus"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="member_id" value={member.id} />
|
||||
<select
|
||||
name="status_id"
|
||||
value={member.membership_status_id || ''}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm cursor-pointer hover:bg-slate-100"
|
||||
>
|
||||
{#each statuses as status}
|
||||
<option value={status.id}>{status.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{formatDate(member.member_since)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button
|
||||
onclick={() => confirmDelete(member)}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="Delete Member"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm && memberToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-4 flex items-center gap-3 text-red-600">
|
||||
<AlertTriangle class="h-6 w-6" />
|
||||
<h3 class="text-lg font-semibold">Delete Member</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-slate-600">
|
||||
Are you sure you want to delete <strong>{memberToDelete.first_name} {memberToDelete.last_name}</strong>
|
||||
({memberToDelete.member_id})? This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<p class="mb-6 text-sm text-slate-500">
|
||||
This will permanently delete their account, payment history, and all associated data.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
showDeleteConfirm = false;
|
||||
memberToDelete = null;
|
||||
}}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteMember"
|
||||
use:enhance={() => {
|
||||
return async ({ update, result }) => {
|
||||
if (result.type === 'success') {
|
||||
showDeleteConfirm = false;
|
||||
memberToDelete = null;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex-1"
|
||||
>
|
||||
<input type="hidden" name="member_id" value={memberToDelete.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Member
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Invite Member Modal -->
|
||||
{#if showInviteModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-4 flex items-center gap-3 text-monaco-600">
|
||||
<UserPlus class="h-6 w-6" />
|
||||
<h3 class="text-lg font-semibold text-slate-900">Invite New Member</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 text-sm text-slate-600">
|
||||
Send an invitation email to a new member. They will receive instructions to set up their account.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/inviteMember"
|
||||
use:enhance={() => {
|
||||
inviteLoading = true;
|
||||
return async ({ update, result }) => {
|
||||
inviteLoading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="invite_email">Email Address *</Label>
|
||||
<Input
|
||||
id="invite_email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="member@example.com"
|
||||
bind:value={inviteEmail}
|
||||
required
|
||||
disabled={inviteLoading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="invite_first_name">First Name</Label>
|
||||
<Input
|
||||
id="invite_first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
bind:value={inviteFirstName}
|
||||
disabled={inviteLoading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="invite_last_name">Last Name</Label>
|
||||
<Input
|
||||
id="invite_last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
bind:value={inviteLastName}
|
||||
disabled={inviteLoading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="invite_role">Role</Label>
|
||||
<select
|
||||
id="invite_role"
|
||||
name="role"
|
||||
bind:value={inviteRole}
|
||||
disabled={inviteLoading}
|
||||
class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="invite_dues_paid_date">Dues Paid Until</Label>
|
||||
<input
|
||||
id="invite_dues_paid_date"
|
||||
name="dues_paid_date"
|
||||
type="date"
|
||||
value={inviteDuesPaidDate}
|
||||
oninput={(e) => inviteDuesPaidDate = e.currentTarget.value}
|
||||
disabled={inviteLoading}
|
||||
class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500">
|
||||
* Required field. Other fields are optional - the member can update their profile after joining.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showInviteModal = false;
|
||||
inviteEmail = '';
|
||||
inviteFirstName = '';
|
||||
inviteLastName = '';
|
||||
inviteRole = 'member';
|
||||
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||
}}
|
||||
disabled={inviteLoading}
|
||||
class="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="monaco" disabled={inviteLoading || !inviteEmail} class="flex-1">
|
||||
{#if inviteLoading}
|
||||
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Send Invitation
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
709
src/routes/(app)/admin/settings/+page.server.ts
Normal file
709
src/routes/(app)/admin/settings/+page.server.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
|
||||
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
|
||||
import * as poste from '$lib/server/poste';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Load all configurable data
|
||||
const [
|
||||
{ data: membershipStatuses },
|
||||
{ data: membershipTypes },
|
||||
{ data: eventTypes },
|
||||
{ data: documentCategories },
|
||||
{ data: appSettings },
|
||||
{ data: emailTemplates }
|
||||
] = await Promise.all([
|
||||
locals.supabase.from('membership_statuses').select('*').order('sort_order', { ascending: true }),
|
||||
locals.supabase.from('membership_types').select('*').order('sort_order', { ascending: true }),
|
||||
locals.supabase.from('event_types').select('*').order('sort_order', { ascending: true }),
|
||||
locals.supabase.from('document_categories').select('*').order('sort_order', { ascending: true }),
|
||||
locals.supabase.from('app_settings').select('*'),
|
||||
locals.supabase.from('email_templates').select('template_key, template_name, category').eq('is_active', true).order('category').order('template_name')
|
||||
]);
|
||||
|
||||
// Convert settings to object by category
|
||||
const settings: Record<string, Record<string, any>> = {};
|
||||
for (const setting of appSettings || []) {
|
||||
if (!settings[setting.category]) {
|
||||
settings[setting.category] = {};
|
||||
}
|
||||
settings[setting.category][setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
|
||||
return {
|
||||
membershipStatuses: membershipStatuses || [],
|
||||
membershipTypes: membershipTypes || [],
|
||||
eventTypes: eventTypes || [],
|
||||
documentCategories: documentCategories || [],
|
||||
settings,
|
||||
emailTemplates: emailTemplates || []
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
// Membership Status actions
|
||||
createStatus: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const color = formData.get('color') as string;
|
||||
const description = formData.get('description') as string;
|
||||
|
||||
if (!name || !displayName) {
|
||||
return fail(400, { error: 'Name and display name are required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('membership_statuses').insert({
|
||||
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
display_name: displayName,
|
||||
color: color || '#6b7280',
|
||||
description: description || null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Create status error:', error);
|
||||
return fail(500, { error: 'Failed to create status' });
|
||||
}
|
||||
|
||||
return { success: 'Status created successfully!' };
|
||||
},
|
||||
|
||||
deleteStatus: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
const { error } = await locals.supabase.from('membership_statuses').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete status error:', error);
|
||||
return fail(500, { error: 'Failed to delete status' });
|
||||
}
|
||||
|
||||
return { success: 'Status deleted!' };
|
||||
},
|
||||
|
||||
// Membership Type actions
|
||||
createType: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const annualDues = formData.get('annual_dues') as string;
|
||||
const description = formData.get('description') as string;
|
||||
|
||||
if (!name || !displayName || !annualDues) {
|
||||
return fail(400, { error: 'Name, display name, and annual dues are required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('membership_types').insert({
|
||||
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
display_name: displayName,
|
||||
annual_dues: parseFloat(annualDues),
|
||||
description: description || null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Create type error:', error);
|
||||
return fail(500, { error: 'Failed to create membership type' });
|
||||
}
|
||||
|
||||
return { success: 'Membership type created successfully!' };
|
||||
},
|
||||
|
||||
deleteType: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
const { error } = await locals.supabase.from('membership_types').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete type error:', error);
|
||||
return fail(500, { error: 'Failed to delete membership type' });
|
||||
}
|
||||
|
||||
return { success: 'Membership type deleted!' };
|
||||
},
|
||||
|
||||
// Event Type actions
|
||||
createEventType: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const color = formData.get('color') as string;
|
||||
|
||||
if (!name || !displayName) {
|
||||
return fail(400, { error: 'Name and display name are required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('event_types').insert({
|
||||
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
display_name: displayName,
|
||||
color: color || '#3b82f6'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Create event type error:', error);
|
||||
return fail(500, { error: 'Failed to create event type' });
|
||||
}
|
||||
|
||||
return { success: 'Event type created successfully!' };
|
||||
},
|
||||
|
||||
deleteEventType: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
const { error } = await locals.supabase.from('event_types').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete event type error:', error);
|
||||
return fail(500, { error: 'Failed to delete event type' });
|
||||
}
|
||||
|
||||
return { success: 'Event type deleted!' };
|
||||
},
|
||||
|
||||
// Document Category actions
|
||||
createCategory: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const description = formData.get('description') as string;
|
||||
|
||||
if (!name || !displayName) {
|
||||
return fail(400, { error: 'Name and display name are required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('document_categories').insert({
|
||||
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
display_name: displayName,
|
||||
description: description || null
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Create category error:', error);
|
||||
return fail(500, { error: 'Failed to create document category' });
|
||||
}
|
||||
|
||||
return { success: 'Document category created successfully!' };
|
||||
},
|
||||
|
||||
deleteCategory: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id') as string;
|
||||
|
||||
const { error } = await locals.supabase.from('document_categories').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete category error:', error);
|
||||
return fail(500, { error: 'Failed to delete document category' });
|
||||
}
|
||||
|
||||
return { success: 'Document category deleted!' };
|
||||
},
|
||||
|
||||
// Update app settings
|
||||
updateSettings: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
const formData = await request.formData();
|
||||
const category = formData.get('category') as string;
|
||||
|
||||
// Get existing boolean settings for this category to handle unchecked checkboxes
|
||||
const { data: existingSettings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_type')
|
||||
.eq('category', category);
|
||||
|
||||
const existingBooleanKeys = new Set(
|
||||
(existingSettings || [])
|
||||
.filter(s => s.setting_type === 'boolean')
|
||||
.map(s => s.setting_key)
|
||||
);
|
||||
|
||||
// Get all settings from form data
|
||||
const settingsToUpdate: Array<{ key: string; value: any; type: string }> = [];
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key !== 'category' && key.startsWith('setting_')) {
|
||||
const settingKey = key.replace('setting_', '');
|
||||
processedKeys.add(settingKey);
|
||||
// Handle checkbox values - they come as 'on' when checked
|
||||
const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey);
|
||||
settingsToUpdate.push({
|
||||
key: settingKey,
|
||||
value: isCheckbox ? (value === 'on' || value === 'true') : value,
|
||||
type: isCheckbox ? 'boolean' : 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle unchecked checkboxes - they don't send any value
|
||||
// For any existing boolean setting NOT in the form data, set to false
|
||||
for (const booleanKey of existingBooleanKeys) {
|
||||
if (!processedKeys.has(booleanKey)) {
|
||||
settingsToUpdate.push({
|
||||
key: booleanKey,
|
||||
value: false,
|
||||
type: 'boolean'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const setting of settingsToUpdate) {
|
||||
// Try to update first
|
||||
const { data: existing } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('id')
|
||||
.eq('category', category)
|
||||
.eq('setting_key', setting.key)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
await locals.supabase
|
||||
.from('app_settings')
|
||||
.update({
|
||||
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: member?.id
|
||||
})
|
||||
.eq('category', category)
|
||||
.eq('setting_key', setting.key);
|
||||
} else {
|
||||
// Insert new setting
|
||||
await locals.supabase
|
||||
.from('app_settings')
|
||||
.insert({
|
||||
category,
|
||||
setting_key: setting.key,
|
||||
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
||||
setting_type: setting.type,
|
||||
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: member?.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear caches if storage settings were updated
|
||||
if (category === 'storage') {
|
||||
clearS3ClientCache();
|
||||
}
|
||||
|
||||
return { success: 'Settings updated successfully!' };
|
||||
},
|
||||
|
||||
// Test SMTP connection
|
||||
testSmtp: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
const formData = await request.formData();
|
||||
const testEmail = formData.get('test_email') as string;
|
||||
|
||||
// Use the member's email if no test email is provided
|
||||
const recipientEmail = testEmail || member?.email;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return fail(400, { error: 'No email address provided for test' });
|
||||
}
|
||||
|
||||
// Test SMTP connection and send a test email
|
||||
const result = await testSmtpConnection(recipientEmail, member?.id);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'SMTP test failed' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Test email sent successfully to ${recipientEmail}! Check your inbox.`
|
||||
};
|
||||
},
|
||||
|
||||
// Test S3/MinIO connection
|
||||
testS3: async () => {
|
||||
// Clear cache to ensure fresh settings are used
|
||||
clearS3ClientCache();
|
||||
|
||||
// Test S3 connection using the actual client
|
||||
const result = await testS3Connection();
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'S3 connection test failed' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: 'S3/MinIO connection successful! Bucket is accessible.'
|
||||
};
|
||||
},
|
||||
|
||||
// Test email template
|
||||
testEmailTemplate: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member?.email) {
|
||||
return fail(400, { error: 'No email address found for your account' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const templateKey = formData.get('template_key') as string;
|
||||
|
||||
if (!templateKey) {
|
||||
return fail(400, { error: 'Template key is required' });
|
||||
}
|
||||
|
||||
// Get full member details including member_id
|
||||
const { data: fullMember } = await locals.supabase
|
||||
.from('members')
|
||||
.select('member_id')
|
||||
.eq('id', member.id)
|
||||
.single();
|
||||
|
||||
const memberId = fullMember?.member_id || 'MUSA-0001';
|
||||
|
||||
// Create sample variables for each template type
|
||||
const sampleVariables: Record<string, Record<string, string>> = {
|
||||
// Welcome/Auth templates
|
||||
welcome: {
|
||||
first_name: member.first_name || 'Test',
|
||||
last_name: member.last_name || 'User',
|
||||
member_id: memberId,
|
||||
portal_url: url.origin
|
||||
},
|
||||
password_reset: {
|
||||
first_name: member.first_name || 'Test',
|
||||
reset_link: `${url.origin}/reset-password?token=sample-token`
|
||||
},
|
||||
email_verification: {
|
||||
first_name: member.first_name || 'Test',
|
||||
verification_link: `${url.origin}/verify?token=sample-token`
|
||||
},
|
||||
// Event templates
|
||||
rsvp_confirmation: {
|
||||
first_name: member.first_name || 'Test',
|
||||
event_title: 'Monaco USA Annual Gala',
|
||||
event_date: 'Saturday, March 15, 2026',
|
||||
event_time: '7:00 PM',
|
||||
event_location: 'Hotel Hermitage, Monaco',
|
||||
guest_count: '2',
|
||||
portal_url: `${url.origin}/events`
|
||||
},
|
||||
waitlist_promotion: {
|
||||
first_name: member.first_name || 'Test',
|
||||
event_title: 'Monaco USA Annual Gala',
|
||||
event_date: 'Saturday, March 15, 2026',
|
||||
event_location: 'Hotel Hermitage, Monaco',
|
||||
portal_url: `${url.origin}/events`
|
||||
},
|
||||
event_reminder_24hr: {
|
||||
first_name: member.first_name || 'Test',
|
||||
event_title: 'Monaco USA Monthly Meetup',
|
||||
event_date: 'Tomorrow, January 25, 2026',
|
||||
event_time: '6:30 PM',
|
||||
event_location: 'Stars\'n\'Bars, Monaco',
|
||||
guest_count: '1',
|
||||
portal_url: `${url.origin}/events/sample-event-id`
|
||||
},
|
||||
// Payment/Dues templates
|
||||
payment_received: {
|
||||
first_name: member.first_name || 'Test',
|
||||
amount: '€50.00',
|
||||
payment_date: 'January 24, 2026',
|
||||
payment_method: 'Bank Transfer',
|
||||
new_due_date: 'January 24, 2027',
|
||||
member_id: memberId
|
||||
},
|
||||
dues_reminder_30: {
|
||||
first_name: member.first_name || 'Test',
|
||||
due_date: 'February 24, 2026',
|
||||
amount: '€50.00',
|
||||
member_id: memberId,
|
||||
account_holder: 'Monaco USA Association',
|
||||
bank_name: 'CMB Monaco',
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
},
|
||||
dues_reminder_7: {
|
||||
first_name: member.first_name || 'Test',
|
||||
due_date: 'January 31, 2026',
|
||||
amount: '€50.00',
|
||||
member_id: memberId,
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
},
|
||||
dues_reminder_1: {
|
||||
first_name: member.first_name || 'Test',
|
||||
due_date: 'January 25, 2026',
|
||||
amount: '€50.00',
|
||||
member_id: memberId,
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
},
|
||||
dues_overdue: {
|
||||
first_name: member.first_name || 'Test',
|
||||
due_date: 'January 15, 2026',
|
||||
amount: '€50.00',
|
||||
days_overdue: '9',
|
||||
grace_days_remaining: '21',
|
||||
member_id: memberId,
|
||||
account_holder: 'Monaco USA Association',
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
},
|
||||
dues_grace_warning: {
|
||||
first_name: member.first_name || 'Test',
|
||||
due_date: 'December 24, 2025',
|
||||
amount: '€50.00',
|
||||
days_overdue: '31',
|
||||
grace_days_remaining: '7',
|
||||
grace_end_date: 'February 1, 2026',
|
||||
member_id: memberId,
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
},
|
||||
dues_inactive_notice: {
|
||||
first_name: member.first_name || 'Test',
|
||||
amount: '€50.00',
|
||||
member_id: memberId,
|
||||
account_holder: 'Monaco USA Association',
|
||||
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||
portal_url: `${url.origin}/payments`
|
||||
}
|
||||
};
|
||||
|
||||
// Get variables for this template, or use defaults
|
||||
const variables = sampleVariables[templateKey] || {
|
||||
first_name: member.first_name || 'Test',
|
||||
last_name: member.last_name || 'User',
|
||||
portal_url: url.origin
|
||||
};
|
||||
|
||||
// Send test email
|
||||
const result = await sendTemplatedEmail(templateKey, member.email, variables, {
|
||||
recipientId: member.id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`,
|
||||
baseUrl: url.origin
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'Failed to send test email' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Test email "${templateKey}" sent to ${member.email}`
|
||||
};
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// Poste Mail Server Actions
|
||||
// ============================================
|
||||
|
||||
testPoste: async ({ locals }) => {
|
||||
// Get Poste settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
if (!settings || settings.length === 0) {
|
||||
return fail(400, { error: 'Poste mail server not configured. Please save settings first.' });
|
||||
}
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const s of settings) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
config[s.setting_key] = value as string;
|
||||
}
|
||||
|
||||
if (!config.poste_api_host || !config.poste_admin_email || !config.poste_admin_password) {
|
||||
return fail(400, { error: 'Poste configuration incomplete. Host, admin email, and password are required.' });
|
||||
}
|
||||
|
||||
const result = await poste.testConnection({
|
||||
host: config.poste_api_host,
|
||||
adminEmail: config.poste_admin_email,
|
||||
adminPassword: config.poste_admin_password
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'Connection test failed' });
|
||||
}
|
||||
|
||||
return { success: 'Connection to Poste mail server successful!' };
|
||||
},
|
||||
|
||||
listMailboxes: async ({ locals }) => {
|
||||
// Get Poste settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
if (!settings || settings.length === 0) {
|
||||
return fail(400, { error: 'Poste not configured' });
|
||||
}
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const s of settings) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
config[s.setting_key] = value as string;
|
||||
}
|
||||
|
||||
const result = await poste.listMailboxes({
|
||||
host: config.poste_api_host,
|
||||
adminEmail: config.poste_admin_email,
|
||||
adminPassword: config.poste_admin_password
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error });
|
||||
}
|
||||
|
||||
return { mailboxes: result.mailboxes };
|
||||
},
|
||||
|
||||
createMailbox: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const emailPrefix = formData.get('email_prefix') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
if (!emailPrefix || !displayName) {
|
||||
return fail(400, { error: 'Email prefix and display name are required' });
|
||||
}
|
||||
|
||||
// Get Poste settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const s of settings || []) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
config[s.setting_key] = value as string;
|
||||
}
|
||||
|
||||
const domain = config.poste_domain || 'monacousa.org';
|
||||
const fullEmail = `${emailPrefix}@${domain}`;
|
||||
const actualPassword = password || poste.generatePassword();
|
||||
|
||||
const result = await poste.createMailbox(
|
||||
{
|
||||
host: config.poste_api_host,
|
||||
adminEmail: config.poste_admin_email,
|
||||
adminPassword: config.poste_admin_password
|
||||
},
|
||||
{
|
||||
email: fullEmail,
|
||||
name: displayName,
|
||||
password: actualPassword
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Mailbox ${fullEmail} created successfully!`,
|
||||
generatedPassword: password ? undefined : actualPassword
|
||||
};
|
||||
},
|
||||
|
||||
updateMailbox: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
const displayName = formData.get('display_name') as string;
|
||||
const newPassword = formData.get('new_password') as string;
|
||||
const disabled = formData.get('disabled') === 'true';
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { error: 'Email is required' });
|
||||
}
|
||||
|
||||
// Get Poste settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const s of settings || []) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
config[s.setting_key] = value as string;
|
||||
}
|
||||
|
||||
const updates: { name?: string; password?: string; disabled?: boolean } = {};
|
||||
if (displayName) updates.name = displayName;
|
||||
if (newPassword) updates.password = newPassword;
|
||||
updates.disabled = disabled;
|
||||
|
||||
const result = await poste.updateMailbox(
|
||||
{
|
||||
host: config.poste_api_host,
|
||||
adminEmail: config.poste_admin_email,
|
||||
adminPassword: config.poste_admin_password
|
||||
},
|
||||
email,
|
||||
updates
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error });
|
||||
}
|
||||
|
||||
return { success: `Mailbox ${email} updated successfully!` };
|
||||
},
|
||||
|
||||
deleteMailbox: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { error: 'Email is required' });
|
||||
}
|
||||
|
||||
// Get Poste settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
const config: Record<string, string> = {};
|
||||
for (const s of settings || []) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
config[s.setting_key] = value as string;
|
||||
}
|
||||
|
||||
const result = await poste.deleteMailbox(
|
||||
{
|
||||
host: config.poste_api_host,
|
||||
adminEmail: config.poste_admin_email,
|
||||
adminPassword: config.poste_admin_password
|
||||
},
|
||||
email
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error });
|
||||
}
|
||||
|
||||
return { success: `Mailbox ${email} deleted successfully!` };
|
||||
}
|
||||
};
|
||||
1703
src/routes/(app)/admin/settings/+page.svelte
Normal file
1703
src/routes/(app)/admin/settings/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
13
src/routes/(app)/board/+layout.server.ts
Normal file
13
src/routes/(app)/board/+layout.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Only board and admin can access board pages
|
||||
if (member?.role !== 'board' && member?.role !== 'admin') {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
480
src/routes/(app)/board/documents/+page.server.ts
Normal file
480
src/routes/(app)/board/documents/+page.server.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { uploadDocument, deleteDocument, getSignedUrl, isS3Enabled, getActiveDocumentUrl } from '$lib/server/storage';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const folderId = url.searchParams.get('folder');
|
||||
|
||||
// Load folders in current directory
|
||||
let foldersQuery = locals.supabase
|
||||
.from('document_folders')
|
||||
.select(`
|
||||
*,
|
||||
creator:members!document_folders_created_by_fkey(first_name, last_name)
|
||||
`)
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (folderId) {
|
||||
foldersQuery = foldersQuery.eq('parent_id', folderId);
|
||||
} else {
|
||||
foldersQuery = foldersQuery.is('parent_id', null);
|
||||
}
|
||||
|
||||
const { data: folders } = await foldersQuery;
|
||||
|
||||
// Load documents in current folder
|
||||
let documentsQuery = locals.supabase
|
||||
.from('documents')
|
||||
.select(`
|
||||
*,
|
||||
category:document_categories(id, name, display_name, icon),
|
||||
uploader:members!documents_uploaded_by_fkey(first_name, last_name)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (folderId) {
|
||||
documentsQuery = documentsQuery.eq('folder_id', folderId);
|
||||
} else {
|
||||
documentsQuery = documentsQuery.is('folder_id', null);
|
||||
}
|
||||
|
||||
const { data: documents } = await documentsQuery;
|
||||
|
||||
// Load current folder details for breadcrumbs
|
||||
let currentFolder = null;
|
||||
let breadcrumbs: { id: string | null; name: string }[] = [{ id: null, name: 'Documents' }];
|
||||
|
||||
if (folderId) {
|
||||
const { data: folder } = await locals.supabase
|
||||
.from('document_folders')
|
||||
.select('*')
|
||||
.eq('id', folderId)
|
||||
.single();
|
||||
|
||||
currentFolder = folder;
|
||||
|
||||
// Build breadcrumb path
|
||||
if (folder?.path) {
|
||||
const pathParts = folder.path.split('/');
|
||||
let currentPath = '';
|
||||
|
||||
// Get all ancestor folders
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${pathParts[i]}` : pathParts[i];
|
||||
const { data: ancestorFolder } = await locals.supabase
|
||||
.from('document_folders')
|
||||
.select('id, name')
|
||||
.eq('path', currentPath)
|
||||
.single();
|
||||
|
||||
if (ancestorFolder) {
|
||||
breadcrumbs.push({ id: ancestorFolder.id, name: ancestorFolder.name });
|
||||
}
|
||||
}
|
||||
breadcrumbs.push({ id: folder.id, name: folder.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Load categories
|
||||
const { data: categories } = await locals.supabase
|
||||
.from('document_categories')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
// Resolve active URL for each document based on current storage settings
|
||||
const s3Enabled = await isS3Enabled();
|
||||
const documentsWithActiveUrl = (documents || []).map((doc: any) => ({
|
||||
...doc,
|
||||
// Compute active URL based on storage setting
|
||||
active_url: s3Enabled
|
||||
? (doc.file_url_s3 || doc.file_path)
|
||||
: (doc.file_url_local || doc.file_path)
|
||||
}));
|
||||
|
||||
return {
|
||||
documents: documentsWithActiveUrl,
|
||||
folders: folders || [],
|
||||
categories: categories || [],
|
||||
currentFolder,
|
||||
currentFolderId: folderId,
|
||||
breadcrumbs
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createFolder: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to create folders' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = (formData.get('name') as string)?.trim();
|
||||
const parentId = formData.get('parent_id') as string | null;
|
||||
const visibility = (formData.get('visibility') as string) || 'members';
|
||||
|
||||
if (!name) {
|
||||
return fail(400, { error: 'Folder name is required' });
|
||||
}
|
||||
|
||||
// Validate folder name
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
return fail(400, { error: 'Folder name cannot contain slashes' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('document_folders').insert({
|
||||
name,
|
||||
parent_id: parentId || null,
|
||||
visibility,
|
||||
created_by: member.id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Create folder error:', error);
|
||||
if (error.code === '23505') {
|
||||
return fail(400, { error: 'A folder with this name already exists here' });
|
||||
}
|
||||
return fail(500, { error: 'Failed to create folder' });
|
||||
}
|
||||
|
||||
return { success: 'Folder created!' };
|
||||
},
|
||||
|
||||
renameFolder: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to rename folders' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const folderId = formData.get('folder_id') as string;
|
||||
const name = (formData.get('name') as string)?.trim();
|
||||
|
||||
if (!folderId || !name) {
|
||||
return fail(400, { error: 'Folder ID and name are required' });
|
||||
}
|
||||
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
return fail(400, { error: 'Folder name cannot contain slashes' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('document_folders')
|
||||
.update({ name })
|
||||
.eq('id', folderId);
|
||||
|
||||
if (error) {
|
||||
console.error('Rename folder error:', error);
|
||||
if (error.code === '23505') {
|
||||
return fail(400, { error: 'A folder with this name already exists here' });
|
||||
}
|
||||
return fail(500, { error: 'Failed to rename folder' });
|
||||
}
|
||||
|
||||
return { success: 'Folder renamed!' };
|
||||
},
|
||||
|
||||
deleteFolder: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can delete folders' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const folderId = formData.get('folder_id') as string;
|
||||
|
||||
if (!folderId) {
|
||||
return fail(400, { error: 'Folder ID is required' });
|
||||
}
|
||||
|
||||
// Check if folder has documents
|
||||
const { data: docs } = await locals.supabase
|
||||
.from('documents')
|
||||
.select('id')
|
||||
.eq('folder_id', folderId)
|
||||
.limit(1);
|
||||
|
||||
if (docs && docs.length > 0) {
|
||||
return fail(400, { error: 'Cannot delete folder with documents. Move or delete documents first.' });
|
||||
}
|
||||
|
||||
// Check if folder has subfolders
|
||||
const { data: subfolders } = await locals.supabase
|
||||
.from('document_folders')
|
||||
.select('id')
|
||||
.eq('parent_id', folderId)
|
||||
.limit(1);
|
||||
|
||||
if (subfolders && subfolders.length > 0) {
|
||||
return fail(400, { error: 'Cannot delete folder with subfolders. Delete subfolders first.' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('document_folders')
|
||||
.delete()
|
||||
.eq('id', folderId);
|
||||
|
||||
if (error) {
|
||||
console.error('Delete folder error:', error);
|
||||
return fail(500, { error: 'Failed to delete folder' });
|
||||
}
|
||||
|
||||
return { success: 'Folder deleted!' };
|
||||
},
|
||||
|
||||
moveDocument: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to move documents' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentId = formData.get('document_id') as string;
|
||||
const folderId = formData.get('folder_id') as string | null;
|
||||
|
||||
if (!documentId) {
|
||||
return fail(400, { error: 'Document ID is required' });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase
|
||||
.from('documents')
|
||||
.update({
|
||||
folder_id: folderId || null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', documentId);
|
||||
|
||||
if (error) {
|
||||
console.error('Move document error:', error);
|
||||
return fail(500, { error: 'Failed to move document' });
|
||||
}
|
||||
|
||||
return { success: 'Document moved!' };
|
||||
},
|
||||
|
||||
upload: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to upload documents' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const categoryId = formData.get('category_id') as string;
|
||||
const visibility = formData.get('visibility') as string;
|
||||
const folderId = formData.get('folder_id') as string | null;
|
||||
|
||||
// Validation
|
||||
if (!file || !file.size) {
|
||||
return fail(400, { error: 'Please select a file to upload' });
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return fail(400, { error: 'Title is required' });
|
||||
}
|
||||
|
||||
// Upload using dual-storage document service (uploads to both S3 and Supabase Storage)
|
||||
const uploadResult = await uploadDocument(file);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
console.error('Upload error:', uploadResult.error);
|
||||
return fail(500, { error: uploadResult.error || 'Failed to upload file. Please try again.' });
|
||||
}
|
||||
|
||||
// Create document record with both URLs for storage flexibility
|
||||
const { error: insertError } = await locals.supabase.from('documents').insert({
|
||||
title,
|
||||
description: description || null,
|
||||
category_id: categoryId || null,
|
||||
folder_id: folderId || null,
|
||||
// Primary URL (computed based on active storage setting)
|
||||
file_path: uploadResult.publicUrl || uploadResult.path,
|
||||
// Dual storage URLs
|
||||
file_url_local: uploadResult.localUrl || null,
|
||||
file_url_s3: uploadResult.s3Url || null,
|
||||
storage_path: uploadResult.path,
|
||||
// File metadata
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
visibility: visibility || 'members',
|
||||
uploaded_by: member.id
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
// Try to clean up uploaded files from both backends
|
||||
if (uploadResult.path) {
|
||||
await deleteDocument(uploadResult.path);
|
||||
}
|
||||
console.error('Insert error:', insertError);
|
||||
return fail(500, { error: 'Failed to save document. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Document uploaded successfully!' };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can delete documents' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentId = formData.get('document_id') as string;
|
||||
|
||||
if (!documentId) {
|
||||
return fail(400, { error: 'Document ID is required' });
|
||||
}
|
||||
|
||||
// Get document to find storage path
|
||||
const { data: doc } = await locals.supabase
|
||||
.from('documents')
|
||||
.select('storage_path, file_path')
|
||||
.eq('id', documentId)
|
||||
.single();
|
||||
|
||||
// Delete from database
|
||||
const { error: deleteError } = await locals.supabase
|
||||
.from('documents')
|
||||
.delete()
|
||||
.eq('id', documentId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Delete error:', deleteError);
|
||||
return fail(500, { error: 'Failed to delete document' });
|
||||
}
|
||||
|
||||
// Delete from ALL storage backends (both S3 and Supabase Storage)
|
||||
if (doc?.storage_path) {
|
||||
// Use the storage_path directly
|
||||
await deleteDocument(doc.storage_path);
|
||||
} else if (doc?.file_path) {
|
||||
// Fallback for older documents without storage_path
|
||||
try {
|
||||
let storagePath = doc.file_path;
|
||||
|
||||
// If it's a URL, extract the path
|
||||
if (doc.file_path.startsWith('http')) {
|
||||
const url = new URL(doc.file_path);
|
||||
// Handle Supabase storage URL format
|
||||
const supabaseMatch = url.pathname.match(/\/storage\/v1\/object\/public\/documents\/(.+)/);
|
||||
if (supabaseMatch) {
|
||||
storagePath = supabaseMatch[1];
|
||||
} else {
|
||||
// Handle S3 URL format
|
||||
const s3Match = url.pathname.match(/\/documents\/(.+)/);
|
||||
if (s3Match) {
|
||||
storagePath = s3Match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await deleteDocument(storagePath);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete file from storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: 'Document deleted successfully!' };
|
||||
},
|
||||
|
||||
updateVisibility: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to update documents' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentId = formData.get('document_id') as string;
|
||||
const visibility = formData.get('visibility') as string;
|
||||
|
||||
if (!documentId || !visibility) {
|
||||
return fail(400, { error: 'Document ID and visibility are required' });
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('documents')
|
||||
.update({
|
||||
visibility,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', documentId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Update error:', updateError);
|
||||
return fail(500, { error: 'Failed to update document' });
|
||||
}
|
||||
|
||||
return { success: 'Visibility updated!' };
|
||||
},
|
||||
|
||||
getPreviewUrl: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const documentId = formData.get('document_id') as string;
|
||||
|
||||
if (!documentId) {
|
||||
return fail(400, { error: 'Document ID is required' });
|
||||
}
|
||||
|
||||
// Get document with all URL columns
|
||||
const { data: doc } = await locals.supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('id', documentId)
|
||||
.single();
|
||||
|
||||
if (!doc) {
|
||||
return fail(404, { error: 'Document not found' });
|
||||
}
|
||||
|
||||
// Check visibility permissions
|
||||
const canAccess =
|
||||
doc.visibility === 'public' ||
|
||||
(doc.visibility === 'members') ||
|
||||
(doc.visibility === 'board' && ['board', 'admin'].includes(member.role)) ||
|
||||
(doc.visibility === 'admin' && member.role === 'admin');
|
||||
|
||||
if (!canAccess) {
|
||||
return fail(403, { error: 'You do not have permission to view this document' });
|
||||
}
|
||||
|
||||
// Get the active URL based on current storage settings
|
||||
const activeUrl = await getActiveDocumentUrl({
|
||||
file_url_s3: doc.file_url_s3,
|
||||
file_url_local: doc.file_url_local,
|
||||
file_path: doc.file_path
|
||||
});
|
||||
|
||||
// If we have a public URL, return it
|
||||
if (activeUrl && activeUrl.startsWith('http')) {
|
||||
return { previewUrl: activeUrl };
|
||||
}
|
||||
|
||||
// Generate signed URL for private storage using storage_path or file_path
|
||||
const storagePath = doc.storage_path || doc.file_path;
|
||||
const { url, error } = await getSignedUrl('documents', storagePath, 3600);
|
||||
|
||||
if (error || !url) {
|
||||
return fail(500, { error: 'Failed to generate preview URL' });
|
||||
}
|
||||
|
||||
return { previewUrl: url };
|
||||
}
|
||||
};
|
||||
719
src/routes/(app)/board/documents/+page.svelte
Normal file
719
src/routes/(app)/board/documents/+page.svelte
Normal file
@@ -0,0 +1,719 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
Search,
|
||||
Trash2,
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Calendar,
|
||||
User,
|
||||
Filter,
|
||||
ArrowUpRight
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import {
|
||||
DocumentPreviewModal,
|
||||
FolderItem,
|
||||
FolderBreadcrumbs,
|
||||
CreateFolderModal
|
||||
} from '$lib/components/documents';
|
||||
|
||||
let { data, form } = $props();
|
||||
const { documents, folders, categories, member, currentFolderId, breadcrumbs } = data;
|
||||
|
||||
let searchQuery = $state('');
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
let showUploadModal = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let showCreateFolderModal = $state(false);
|
||||
let showPreviewModal = $state(false);
|
||||
let documentToDelete = $state<any>(null);
|
||||
let documentToPreview = $state<any>(null);
|
||||
let previewUrl = $state<string>('');
|
||||
let isSubmitting = $state(false);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let dragOver = $state(false);
|
||||
let renamingFolder = $state<any>(null);
|
||||
let showRenameFolderModal = $state(false);
|
||||
|
||||
// Filter documents
|
||||
const filteredDocuments = $derived(
|
||||
(documents || []).filter((doc: any) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
doc.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = !selectedCategory || doc.category_id === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter folders
|
||||
const filteredFolders = $derived(
|
||||
(folders || []).filter((folder: any) => {
|
||||
if (!searchQuery) return true;
|
||||
return folder.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
);
|
||||
|
||||
// Check permissions
|
||||
const canEdit = $derived(member?.role === 'board' || member?.role === 'admin');
|
||||
const canDelete = $derived(member?.role === 'admin');
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Get visibility label
|
||||
function getVisibilityLabel(visibility: string) {
|
||||
switch (visibility) {
|
||||
case 'public':
|
||||
return { label: 'Public', color: 'bg-green-100 text-green-700' };
|
||||
case 'members':
|
||||
return { label: 'Members', color: 'bg-blue-100 text-blue-700' };
|
||||
case 'board':
|
||||
return { label: 'Board', color: 'bg-purple-100 text-purple-700' };
|
||||
case 'admin':
|
||||
return { label: 'Admin', color: 'bg-red-100 text-red-700' };
|
||||
default:
|
||||
return { label: visibility, color: 'bg-slate-100 text-slate-700' };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
selectedFile = input.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag and drop
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
|
||||
selectedFile = e.dataTransfer.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
// Navigate to folder
|
||||
function navigateToFolder(folderId: string | null) {
|
||||
if (folderId) {
|
||||
goto(`/board/documents?folder=${folderId}`);
|
||||
} else {
|
||||
goto('/board/documents');
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete
|
||||
function confirmDelete(doc: any) {
|
||||
documentToDelete = doc;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
// Open preview
|
||||
async function openPreview(doc: any) {
|
||||
documentToPreview = doc;
|
||||
// Get the preview URL (uses active_url which is computed based on storage setting)
|
||||
previewUrl = doc.active_url || doc.file_path;
|
||||
showPreviewModal = true;
|
||||
}
|
||||
|
||||
// Reset upload form
|
||||
function resetUploadForm() {
|
||||
selectedFile = null;
|
||||
showUploadModal = false;
|
||||
}
|
||||
|
||||
// Handle folder rename
|
||||
function handleRenameFolder(folder: any) {
|
||||
renamingFolder = folder;
|
||||
showRenameFolderModal = true;
|
||||
}
|
||||
|
||||
// Handle folder delete
|
||||
function handleDeleteFolder(folder: any) {
|
||||
documentToDelete = { ...folder, isFolder: true };
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Document Management | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Document Management</h1>
|
||||
<p class="text-slate-500">Upload and manage association documents</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if canEdit}
|
||||
<button
|
||||
onclick={() => (showCreateFolderModal = true)}
|
||||
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<FolderPlus class="h-5 w-5" />
|
||||
New Folder
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => (showUploadModal = true)}
|
||||
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 font-medium text-white hover:bg-monaco-700"
|
||||
>
|
||||
<Upload class="h-5 w-5" />
|
||||
Upload Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||
{form.success}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
{#if breadcrumbs && breadcrumbs.length > 0}
|
||||
<div class="glass-card p-4">
|
||||
<FolderBreadcrumbs {breadcrumbs} onNavigate={navigateToFolder} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search documents and folders..."
|
||||
bind:value={searchQuery}
|
||||
class="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
bind:value={selectedCategory}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={null}>All Categories</option>
|
||||
{#each categories as category}
|
||||
<option value={category.id}>{category.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folders Grid -->
|
||||
{#if filteredFolders.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-3 text-sm font-medium text-slate-500 uppercase tracking-wide">Folders</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each filteredFolders as folder}
|
||||
<FolderItem
|
||||
{folder}
|
||||
{canEdit}
|
||||
{canDelete}
|
||||
onNavigate={navigateToFolder}
|
||||
onRename={handleRenameFolder}
|
||||
onDelete={handleDeleteFolder}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Documents Table -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
{#if filteredFolders.length === 0 && filteredDocuments.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<FolderOpen class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">
|
||||
{currentFolderId ? 'This folder is empty' : 'No documents found'}
|
||||
</h3>
|
||||
<p class="mt-1 text-slate-500">
|
||||
{searchQuery || selectedCategory
|
||||
? 'Try adjusting your search or filters.'
|
||||
: currentFolderId
|
||||
? 'Upload documents or create subfolders to get started.'
|
||||
: 'Upload your first document to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if filteredDocuments.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<FileText class="mb-4 h-12 w-12 text-slate-300" />
|
||||
<h3 class="text-base font-medium text-slate-900">No documents in this location</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Documents you upload here will appear in this list.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<h2 class="px-6 py-3 text-sm font-medium text-slate-500 uppercase tracking-wide bg-slate-50/50">
|
||||
Documents ({filteredDocuments.length})
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Document
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Category
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Visibility
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Uploaded
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each filteredDocuments as doc}
|
||||
{@const visInfo = getVisibilityLabel(doc.visibility)}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<button
|
||||
onclick={() => openPreview(doc)}
|
||||
class="flex items-center gap-3 text-left hover:text-monaco-600 transition-colors"
|
||||
>
|
||||
<FileText class="h-5 w-5 text-monaco-600 flex-shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-slate-900 truncate">{doc.title}</p>
|
||||
<p class="text-xs text-slate-500 truncate">
|
||||
{doc.file_name} ({formatFileSize(doc.file_size)})
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{doc.category?.display_name || '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateVisibility"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="document_id" value={doc.id} />
|
||||
<select
|
||||
name="visibility"
|
||||
value={doc.visibility}
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-xs font-medium cursor-pointer hover:bg-slate-100 {visInfo.color}"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="members">Members</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">
|
||||
<p class="text-slate-900">{formatDate(doc.created_at)}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
by {doc.uploader?.first_name} {doc.uploader?.last_name}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
onclick={() => openPreview(doc)}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
title="Preview"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</button>
|
||||
<a
|
||||
href={doc.active_url || doc.file_path}
|
||||
download={doc.file_name}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-monaco-600"
|
||||
title="Download"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</a>
|
||||
{#if canDelete}
|
||||
<button
|
||||
onclick={() => confirmDelete(doc)}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Folder Modal -->
|
||||
{#if showCreateFolderModal}
|
||||
<CreateFolderModal
|
||||
parentFolderId={currentFolderId}
|
||||
onClose={() => (showCreateFolderModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Document Preview Modal -->
|
||||
{#if showPreviewModal && documentToPreview}
|
||||
<DocumentPreviewModal
|
||||
document={documentToPreview}
|
||||
{previewUrl}
|
||||
onClose={() => {
|
||||
showPreviewModal = false;
|
||||
documentToPreview = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Modal -->
|
||||
{#if showUploadModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-lg p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Upload Document</h2>
|
||||
<button onclick={resetUploadForm} class="rounded p-1 hover:bg-slate-100">
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update, result }) => {
|
||||
await invalidateAll();
|
||||
isSubmitting = false;
|
||||
if (result.type === 'success') {
|
||||
resetUploadForm();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Hidden folder ID -->
|
||||
{#if currentFolderId}
|
||||
<input type="hidden" name="folder_id" value={currentFolderId} />
|
||||
{/if}
|
||||
|
||||
<!-- File Drop Zone -->
|
||||
<div
|
||||
class="relative rounded-lg border-2 border-dashed p-8 text-center transition-colors
|
||||
{dragOver
|
||||
? 'border-monaco-500 bg-monaco-50'
|
||||
: 'border-slate-300 hover:border-slate-400'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
id="file"
|
||||
class="absolute inset-0 cursor-pointer opacity-0"
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.jpg,.jpeg,.png,.webp,.gif"
|
||||
/>
|
||||
|
||||
{#if selectedFile}
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<FileText class="h-10 w-10 text-monaco-600" />
|
||||
<div class="text-left">
|
||||
<p class="font-medium text-slate-900">{selectedFile.name}</p>
|
||||
<p class="text-sm text-slate-500">{formatFileSize(selectedFile.size)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectedFile = null;
|
||||
}}
|
||||
class="rounded p-1 hover:bg-slate-100"
|
||||
>
|
||||
<X class="h-5 w-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<Upload class="mx-auto h-10 w-10 text-slate-400" />
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
Drag and drop or click to select
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, GIF (max 50MB)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="title">Title *</Label>
|
||||
<Input type="text" id="title" name="title" required class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="2"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
placeholder="Brief description of the document..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label for="category_id">Category</Label>
|
||||
<select
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
{#each categories as category}
|
||||
<option value={category.id}>{category.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="visibility">Visibility</Label>
|
||||
<select
|
||||
id="visibility"
|
||||
name="visibility"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="members">Members Only</option>
|
||||
<option value="board">Board Only</option>
|
||||
<option value="admin">Admin Only</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetUploadForm}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !selectedFile}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
Uploading...
|
||||
{:else}
|
||||
<Upload class="h-4 w-4" />
|
||||
Upload Document
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm && documentToDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-4 flex items-center gap-3 text-red-600">
|
||||
<Trash2 class="h-6 w-6" />
|
||||
<h3 class="text-lg font-semibold">
|
||||
Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-slate-600">
|
||||
Are you sure you want to delete <strong>{documentToDelete.title || documentToDelete.name}</strong>? This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
showDeleteConfirm = false;
|
||||
documentToDelete = null;
|
||||
}}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action={documentToDelete.isFolder ? '?/deleteFolder' : '?/delete'}
|
||||
use:enhance={() => {
|
||||
return async ({ update, result }) => {
|
||||
if (result.type === 'success') {
|
||||
showDeleteConfirm = false;
|
||||
documentToDelete = null;
|
||||
}
|
||||
await invalidateAll();
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex-1"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={documentToDelete.isFolder ? 'folder_id' : 'document_id'}
|
||||
value={documentToDelete.id}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rename Folder Modal -->
|
||||
{#if showRenameFolderModal && renamingFolder}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Rename Folder</h2>
|
||||
<button
|
||||
onclick={() => {
|
||||
showRenameFolderModal = false;
|
||||
renamingFolder = null;
|
||||
}}
|
||||
class="rounded p-1 hover:bg-slate-100"
|
||||
>
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/renameFolder"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update, result }) => {
|
||||
await invalidateAll();
|
||||
isSubmitting = false;
|
||||
if (result.type === 'success') {
|
||||
showRenameFolderModal = false;
|
||||
renamingFolder = null;
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="folder_id" value={renamingFolder.id} />
|
||||
|
||||
<div>
|
||||
<Label for="rename-name">Folder Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="rename-name"
|
||||
name="name"
|
||||
value={renamingFolder.name}
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showRenameFolderModal = false;
|
||||
renamingFolder = null;
|
||||
}}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
Saving...
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
345
src/routes/(app)/board/dues/+page.server.ts
Normal file
345
src/routes/(app)/board/dues/+page.server.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { sendTemplatedEmail } from '$lib/server/email';
|
||||
import { logPaymentAction } from '$lib/server/audit';
|
||||
import {
|
||||
sendBulkReminders,
|
||||
sendDuesReminder,
|
||||
getMembersNeedingReminder,
|
||||
getDuesSettings,
|
||||
type ReminderType
|
||||
} from '$lib/server/dues';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const statusFilter = url.searchParams.get('status') || 'all';
|
||||
const searchQuery = url.searchParams.get('search') || '';
|
||||
const memberId = url.searchParams.get('member') || null;
|
||||
|
||||
// If specific member requested, load their details
|
||||
let selectedMember = null;
|
||||
if (memberId) {
|
||||
const { data } = await locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
selectedMember = data;
|
||||
}
|
||||
|
||||
// Load all members with dues status
|
||||
const { data: members } = await locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.order('last_name', { ascending: true });
|
||||
|
||||
// Filter by dues status
|
||||
let filteredMembers = members || [];
|
||||
if (statusFilter !== 'all') {
|
||||
filteredMembers = filteredMembers.filter((m: any) => m.dues_status === statusFilter);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery) {
|
||||
const lowerSearch = searchQuery.toLowerCase();
|
||||
filteredMembers = filteredMembers.filter(
|
||||
(m: any) =>
|
||||
m.first_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.last_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.member_id?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: members?.length || 0,
|
||||
current: members?.filter((m: any) => m.dues_status === 'current').length || 0,
|
||||
dueSoon: members?.filter((m: any) => m.dues_status === 'due_soon').length || 0,
|
||||
overdue: members?.filter((m: any) => m.dues_status === 'overdue').length || 0,
|
||||
neverPaid: members?.filter((m: any) => m.dues_status === 'never_paid').length || 0
|
||||
};
|
||||
|
||||
// Get recent payments
|
||||
const { data: recentPayments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
member:members(id, first_name, last_name, member_id),
|
||||
recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
|
||||
`
|
||||
)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
// Get membership types for payment recording
|
||||
const { data: membershipTypes } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
return {
|
||||
members: filteredMembers,
|
||||
selectedMember,
|
||||
stats,
|
||||
recentPayments: recentPayments || [],
|
||||
membershipTypes: membershipTypes || [],
|
||||
filters: {
|
||||
status: statusFilter,
|
||||
search: searchQuery,
|
||||
memberId
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
recordPayment: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to record payments' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
const amount = parseFloat(formData.get('amount') as string);
|
||||
const paymentDate = formData.get('payment_date') as string;
|
||||
const paymentMethod = formData.get('payment_method') as string;
|
||||
const reference = formData.get('reference') as string;
|
||||
const notes = formData.get('notes') as string;
|
||||
const sendNotification = formData.get('send_notification') === 'on';
|
||||
|
||||
if (!memberId || !amount || !paymentDate) {
|
||||
return fail(400, { error: 'Member, amount, and payment date are required' });
|
||||
}
|
||||
|
||||
// Get member details for notification
|
||||
const { data: payingMember } = await locals.supabase
|
||||
.from('members')
|
||||
.select('first_name, last_name, email')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
|
||||
// Calculate due date (1 year from payment date)
|
||||
const dueDate = new Date(paymentDate);
|
||||
dueDate.setFullYear(dueDate.getFullYear() + 1);
|
||||
const dueDateStr = dueDate.toISOString().split('T')[0];
|
||||
|
||||
// Record the payment
|
||||
const { data: paymentData, error: paymentError } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.insert({
|
||||
member_id: memberId,
|
||||
amount,
|
||||
payment_date: paymentDate,
|
||||
due_date: dueDateStr,
|
||||
payment_method: paymentMethod || 'bank_transfer',
|
||||
reference: reference || null,
|
||||
notes: notes || null,
|
||||
recorded_by: member.id
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (paymentError) {
|
||||
console.error('Payment recording error:', paymentError);
|
||||
return fail(500, { error: 'Failed to record payment. Please try again.' });
|
||||
}
|
||||
|
||||
// Log audit
|
||||
await logPaymentAction(
|
||||
'record',
|
||||
{ id: member.id, email: member.email },
|
||||
{ id: paymentData?.id, memberId, amount },
|
||||
{ payment_date: paymentDate, payment_method: paymentMethod }
|
||||
);
|
||||
|
||||
// Send email notification if requested
|
||||
if (sendNotification && payingMember?.email) {
|
||||
const formattedDate = new Date(paymentDate).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
const formattedDueDate = dueDate.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
await sendTemplatedEmail(
|
||||
'payment_received',
|
||||
payingMember.email,
|
||||
{
|
||||
first_name: payingMember.first_name,
|
||||
amount: amount.toFixed(2),
|
||||
payment_date: formattedDate,
|
||||
reference: reference || 'N/A',
|
||||
due_date: formattedDueDate
|
||||
},
|
||||
{
|
||||
recipientId: memberId,
|
||||
recipientName: `${payingMember.first_name} ${payingMember.last_name}`,
|
||||
sentBy: member.id,
|
||||
baseUrl: url.origin
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { success: 'Payment recorded successfully!' + (sendNotification ? ' Notification sent.' : '') };
|
||||
},
|
||||
|
||||
/**
|
||||
* Send individual reminder to a specific member
|
||||
*/
|
||||
sendReminder: async ({ request, locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to send reminders' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
const reminderType = formData.get('reminder_type') as ReminderType;
|
||||
|
||||
if (!memberId) {
|
||||
return fail(400, { error: 'Member ID is required' });
|
||||
}
|
||||
|
||||
// Get member details
|
||||
const { data: targetMember } = await supabaseAdmin
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
|
||||
if (!targetMember) {
|
||||
return fail(404, { error: 'Member not found' });
|
||||
}
|
||||
|
||||
// Determine the appropriate reminder type based on their status
|
||||
let effectiveReminderType = reminderType;
|
||||
if (!effectiveReminderType) {
|
||||
if (targetMember.dues_status === 'overdue') {
|
||||
effectiveReminderType = 'overdue';
|
||||
} else if (targetMember.dues_status === 'due_soon') {
|
||||
effectiveReminderType = 'due_soon_7';
|
||||
} else {
|
||||
effectiveReminderType = 'due_soon_30';
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sendDuesReminder(targetMember, effectiveReminderType, url.origin);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(500, { error: result.error || 'Failed to send reminder' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Reminder sent to ${targetMember.first_name} ${targetMember.last_name}`
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send bulk reminders to all members in due_soon status
|
||||
*/
|
||||
sendBulkDueSoonReminders: async ({ locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to send bulk reminders' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
due_soon_30: { sent: 0, errors: [] as string[] },
|
||||
due_soon_7: { sent: 0, errors: [] as string[] },
|
||||
due_soon_1: { sent: 0, errors: [] as string[] }
|
||||
};
|
||||
|
||||
// Get settings to determine which reminder tiers to process
|
||||
const settings = await getDuesSettings();
|
||||
const reminderDays = settings.reminder_days_before || [30, 7, 1];
|
||||
|
||||
let totalSent = 0;
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const days of reminderDays) {
|
||||
const reminderType = `due_soon_${days}` as ReminderType;
|
||||
const result = await sendBulkReminders(reminderType, url.origin);
|
||||
|
||||
results[reminderType as keyof typeof results] = {
|
||||
sent: result.sent,
|
||||
errors: result.errors
|
||||
};
|
||||
totalSent += result.sent;
|
||||
allErrors.push(...result.errors);
|
||||
}
|
||||
|
||||
if (totalSent === 0 && allErrors.length === 0) {
|
||||
return { success: 'No members need due soon reminders at this time.' };
|
||||
}
|
||||
|
||||
const errorMsg = allErrors.length > 0 ? ` (${allErrors.length} errors)` : '';
|
||||
return {
|
||||
success: `Sent ${totalSent} due soon reminder(s)${errorMsg}`,
|
||||
bulkResult: results
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send bulk reminders to all overdue members
|
||||
*/
|
||||
sendBulkOverdueReminders: async ({ locals, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to send bulk reminders' });
|
||||
}
|
||||
|
||||
const result = await sendBulkReminders('overdue', url.origin);
|
||||
|
||||
if (result.sent === 0 && result.errors.length === 0) {
|
||||
return { success: 'No overdue members need reminders at this time.' };
|
||||
}
|
||||
|
||||
const errorMsg = result.errors.length > 0 ? ` (${result.errors.length} errors)` : '';
|
||||
return {
|
||||
success: `Sent ${result.sent} overdue reminder(s)${errorMsg}`,
|
||||
bulkResult: result
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get preview counts for bulk operations
|
||||
*/
|
||||
getBulkPreview: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const settings = await getDuesSettings();
|
||||
const reminderDays = settings.reminder_days_before || [30, 7, 1];
|
||||
|
||||
const preview = {
|
||||
dueSoon: 0,
|
||||
overdue: 0,
|
||||
breakdown: {} as Record<string, number>
|
||||
};
|
||||
|
||||
for (const days of reminderDays) {
|
||||
const reminderType = `due_soon_${days}` as ReminderType;
|
||||
const members = await getMembersNeedingReminder(reminderType);
|
||||
preview.breakdown[reminderType] = members.length;
|
||||
preview.dueSoon += members.length;
|
||||
}
|
||||
|
||||
const overdueMembers = await getMembersNeedingReminder('overdue');
|
||||
preview.overdue = overdueMembers.length;
|
||||
|
||||
return { preview };
|
||||
}
|
||||
};
|
||||
679
src/routes/(app)/board/dues/+page.svelte
Normal file
679
src/routes/(app)/board/dues/+page.svelte
Normal file
@@ -0,0 +1,679 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CreditCard,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Users,
|
||||
X,
|
||||
Mail,
|
||||
Send,
|
||||
FileText,
|
||||
Bell
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { DatePicker } from '$lib/components/ui';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
// Use $derived to make these reactive when data updates after invalidateAll()
|
||||
const members = $derived(data.members);
|
||||
const selectedMember = $derived(data.selectedMember);
|
||||
const stats = $derived(data.stats);
|
||||
const recentPayments = $derived(data.recentPayments);
|
||||
const membershipTypes = $derived(data.membershipTypes);
|
||||
const filters = $derived(data.filters);
|
||||
|
||||
let searchQuery = $state(filters.search);
|
||||
let statusFilter = $state(filters.status);
|
||||
let showFilters = $state(false);
|
||||
let showPaymentModal = $state(!!selectedMember);
|
||||
let selectedMemberForPayment = $state<any>(selectedMember);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
// Bulk action state
|
||||
let showBulkModal = $state(false);
|
||||
let bulkActionType = $state<'dueSoon' | 'overdue' | null>(null);
|
||||
let isBulkSubmitting = $state(false);
|
||||
let bulkResult = $state<any>(null);
|
||||
|
||||
// Individual reminder state
|
||||
let sendingReminderId = $state<string | null>(null);
|
||||
|
||||
// Debounce search
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function handleSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
updateFilters({ search: value });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: Record<string, string>) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(newFilters)) {
|
||||
if (value && value !== 'all') {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
goto(`?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
function openPaymentModal(member: any) {
|
||||
selectedMemberForPayment = member;
|
||||
showPaymentModal = true;
|
||||
}
|
||||
|
||||
function closePaymentModal() {
|
||||
showPaymentModal = false;
|
||||
selectedMemberForPayment = null;
|
||||
// Remove member param from URL
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
params.delete('member');
|
||||
goto(`?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Get dues status info
|
||||
function getDuesInfo(status: string | null) {
|
||||
switch (status) {
|
||||
case 'current':
|
||||
return {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-100',
|
||||
label: 'Current'
|
||||
};
|
||||
case 'due_soon':
|
||||
return { icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-100', label: 'Due Soon' };
|
||||
case 'overdue':
|
||||
return { icon: AlertCircle, color: 'text-red-600', bg: 'bg-red-100', label: 'Overdue' };
|
||||
case 'never_paid':
|
||||
default:
|
||||
return { icon: XCircle, color: 'text-slate-500', bg: 'bg-slate-100', label: 'Never Paid' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get default amount based on membership type
|
||||
function getDefaultAmount(member: any): number {
|
||||
return member?.annual_dues || 50;
|
||||
}
|
||||
|
||||
// Today's date for default payment date
|
||||
const todayDate = today(getLocalTimeZone());
|
||||
let paymentDate = $state<CalendarDate | undefined>(todayDate);
|
||||
|
||||
// Reset payment date when modal opens
|
||||
function openPaymentModalWithReset(member: any) {
|
||||
paymentDate = todayDate;
|
||||
openPaymentModal(member);
|
||||
}
|
||||
|
||||
// Bulk actions
|
||||
function openBulkModal(type: 'dueSoon' | 'overdue') {
|
||||
bulkActionType = type;
|
||||
bulkResult = null;
|
||||
showBulkModal = true;
|
||||
}
|
||||
|
||||
function closeBulkModal() {
|
||||
showBulkModal = false;
|
||||
bulkActionType = null;
|
||||
bulkResult = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dues Management | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Dues Management</h1>
|
||||
<p class="text-slate-500">Track and record membership dues payments</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/board/dues/reports"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<FileText class="h-4 w-4" />
|
||||
Reports
|
||||
</a>
|
||||
<button
|
||||
onclick={() => openBulkModal('dueSoon')}
|
||||
disabled={stats.dueSoon === 0}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-yellow-200 bg-yellow-50 px-3 py-1.5 text-sm font-medium text-yellow-700 hover:bg-yellow-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Bell class="h-4 w-4" />
|
||||
Remind Due Soon ({stats.dueSoon})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openBulkModal('overdue')}
|
||||
disabled={stats.overdue === 0}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
Remind Overdue ({stats.overdue})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<Users class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
|
||||
<p class="text-xs text-slate-500">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.current}</p>
|
||||
<p class="text-xs text-slate-500">Current</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-yellow-100 p-2">
|
||||
<Clock class="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.dueSoon}</p>
|
||||
<p class="text-xs text-slate-500">Due Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-red-100 p-2">
|
||||
<AlertCircle class="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.overdue}</p>
|
||||
<p class="text-xs text-slate-500">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<XCircle class="h-5 w-5 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.neverPaid}</p>
|
||||
<p class="text-xs text-slate-500">Never Paid</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Members List -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search members..."
|
||||
value={searchQuery}
|
||||
oninput={(e) => {
|
||||
searchQuery = e.currentTarget.value;
|
||||
handleSearch(e.currentTarget.value);
|
||||
}}
|
||||
class="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={() => updateFilters({ status: statusFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="due_soon">Due Soon</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="never_paid">Never Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if members.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<CreditCard class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No members found</h3>
|
||||
<p class="mt-1 text-slate-500">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each members as member}
|
||||
{@const duesInfo = getDuesInfo(member.dues_status)}
|
||||
<div class="flex items-center justify-between p-4 hover:bg-slate-50">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if member.avatar_url}
|
||||
<img
|
||||
src={member.avatar_url}
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
|
||||
>
|
||||
{member.first_name?.[0]}{member.last_name?.[0]}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{member.first_name} {member.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">{member.member_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<div class="text-right">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {duesInfo.bg} {duesInfo.color}"
|
||||
>
|
||||
<svelte:component this={duesInfo.icon} class="h-3 w-3" />
|
||||
{duesInfo.label}
|
||||
</span>
|
||||
{#if member.current_due_date}
|
||||
<p class="mt-0.5 text-xs text-slate-500">
|
||||
{member.dues_status === 'overdue'
|
||||
? `${member.days_overdue} days overdue`
|
||||
: `Due: ${formatDate(member.current_due_date)}`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if member.dues_status !== 'current' && member.dues_status !== 'never_paid'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/sendReminder"
|
||||
use:enhance={() => {
|
||||
sendingReminderId = member.id;
|
||||
return async ({ update }) => {
|
||||
sendingReminderId = null;
|
||||
await update();
|
||||
await invalidateAll();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="member_id" value={member.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendingReminderId === member.id}
|
||||
title="Send Reminder Email"
|
||||
class="flex items-center gap-1.5 rounded-lg border {member.dues_status === 'overdue' ? 'border-red-200 bg-red-50 text-red-600 hover:bg-red-100' : 'border-yellow-200 bg-yellow-50 text-yellow-600 hover:bg-yellow-100'} px-2.5 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{#if sendingReminderId === member.id}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
<span class="hidden sm:inline">Sending...</span>
|
||||
{:else}
|
||||
<Mail class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Remind</span>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => openPaymentModalWithReset(member)}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Payments -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-4 py-3">
|
||||
<h2 class="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<Calendar class="h-4 w-4 text-monaco-600" />
|
||||
Recent Payments
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{#if recentPayments.length === 0}
|
||||
<div class="p-8 text-center">
|
||||
<p class="text-sm text-slate-500">No payments recorded yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each recentPayments as payment}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{payment.member?.first_name} {payment.member?.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">{payment.member?.member_id}</p>
|
||||
</div>
|
||||
<p class="font-semibold text-slate-900">€{payment.amount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{formatDate(payment.payment_date)}</span>
|
||||
<span>by {payment.recorder?.first_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Recording Modal -->
|
||||
{#if showPaymentModal && selectedMemberForPayment}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Record Payment</h2>
|
||||
<button onclick={closePaymentModal} class="rounded p-1 hover:bg-slate-100">
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member Info -->
|
||||
<div class="mb-6 flex items-center gap-3 rounded-lg bg-slate-50 p-3">
|
||||
{#if selectedMemberForPayment.avatar_url}
|
||||
<img
|
||||
src={selectedMemberForPayment.avatar_url}
|
||||
alt=""
|
||||
class="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
|
||||
>
|
||||
{selectedMemberForPayment.first_name?.[0]}{selectedMemberForPayment.last_name?.[0]}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{selectedMemberForPayment.first_name} {selectedMemberForPayment.last_name}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
{selectedMemberForPayment.member_id} •
|
||||
{selectedMemberForPayment.membership_type_name || 'Regular Member'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-600">
|
||||
{form.success}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/recordPayment"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update, result }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
setTimeout(closePaymentModal, 1500);
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="member_id" value={selectedMemberForPayment.id} />
|
||||
|
||||
<div>
|
||||
<Label for="amount">Amount (€)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="amount"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
value={getDefaultAmount(selectedMemberForPayment)}
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Payment Date</Label>
|
||||
<div class="mt-1">
|
||||
<DatePicker
|
||||
bind:value={paymentDate}
|
||||
maxValue={todayDate}
|
||||
placeholder="Select payment date"
|
||||
name="payment_date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="payment_method">Payment Method</Label>
|
||||
<select
|
||||
id="payment_method"
|
||||
name="payment_method"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="bank_transfer">Bank Transfer</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="reference">Reference (optional)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="reference"
|
||||
name="reference"
|
||||
placeholder="Transaction reference"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="notes">Notes (optional)</Label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows="2"
|
||||
placeholder="Internal notes..."
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePaymentModal}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
Recording...
|
||||
{:else}
|
||||
<CreditCard class="h-4 w-4" />
|
||||
Record Payment
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bulk Action Modal -->
|
||||
{#if showBulkModal && bulkActionType}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">
|
||||
{bulkActionType === 'dueSoon' ? 'Send Due Soon Reminders' : 'Send Overdue Reminders'}
|
||||
</h2>
|
||||
<button onclick={closeBulkModal} class="rounded p-1 hover:bg-slate-100">
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if bulkResult}
|
||||
<!-- Results View -->
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg bg-green-50 p-4">
|
||||
<p class="font-medium text-green-800">{bulkResult.success || bulkResult}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={closeBulkModal}
|
||||
class="w-full rounded-lg bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Confirmation View -->
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-lg {bulkActionType === 'dueSoon' ? 'bg-yellow-50 border-yellow-200' : 'bg-red-50 border-red-200'} border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if bulkActionType === 'dueSoon'}
|
||||
<Clock class="h-8 w-8 text-yellow-600" />
|
||||
{:else}
|
||||
<AlertCircle class="h-8 w-8 text-red-600" />
|
||||
{/if}
|
||||
<div>
|
||||
<p class="font-medium {bulkActionType === 'dueSoon' ? 'text-yellow-800' : 'text-red-800'}">
|
||||
{bulkActionType === 'dueSoon' ? stats.dueSoon : stats.overdue} member(s) will receive reminders
|
||||
</p>
|
||||
<p class="text-sm {bulkActionType === 'dueSoon' ? 'text-yellow-600' : 'text-red-600'}">
|
||||
{bulkActionType === 'dueSoon'
|
||||
? 'Members with dues due within 30 days'
|
||||
: 'Members with overdue payments'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-slate-600">
|
||||
This will send email reminders to all eligible members who haven't already received a reminder for their current dues period.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={bulkActionType === 'dueSoon' ? '?/sendBulkDueSoonReminders' : '?/sendBulkOverdueReminders'}
|
||||
use:enhance={() => {
|
||||
isBulkSubmitting = true;
|
||||
return async ({ result, update }) => {
|
||||
isBulkSubmitting = false;
|
||||
if (result.type === 'success' && result.data) {
|
||||
bulkResult = result.data;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeBulkModal}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBulkSubmitting}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg {bulkActionType === 'dueSoon' ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-red-600 hover:bg-red-700'} px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{#if isBulkSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="h-4 w-4" />
|
||||
Send Reminders
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Form Success/Error Toast -->
|
||||
{#if form?.success && !showPaymentModal && !showBulkModal}
|
||||
<div class="fixed bottom-4 right-4 z-50 rounded-lg bg-green-50 border border-green-200 p-4 shadow-lg">
|
||||
<p class="text-sm font-medium text-green-800">{form.success}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !showPaymentModal && !showBulkModal}
|
||||
<div class="fixed bottom-4 right-4 z-50 rounded-lg bg-red-50 border border-red-200 p-4 shadow-lg">
|
||||
<p class="text-sm font-medium text-red-800">{form.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
169
src/routes/(app)/board/dues/reports/+page.server.ts
Normal file
169
src/routes/(app)/board/dues/reports/+page.server.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getDuesAnalytics, getDuesReportData, getReminderEffectiveness } from '$lib/server/dues';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return { error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Load analytics data
|
||||
const analytics = await getDuesAnalytics();
|
||||
const effectiveness = await getReminderEffectiveness();
|
||||
|
||||
return {
|
||||
analytics,
|
||||
effectiveness
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
/**
|
||||
* Export members report as CSV
|
||||
*/
|
||||
exportMembers: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const data = await getDuesReportData();
|
||||
|
||||
// Generate CSV
|
||||
const headers = [
|
||||
'Member ID',
|
||||
'Name',
|
||||
'Email',
|
||||
'Membership Type',
|
||||
'Status',
|
||||
'Dues Status',
|
||||
'Annual Dues',
|
||||
'Last Payment',
|
||||
'Due Date',
|
||||
'Days Overdue'
|
||||
];
|
||||
|
||||
const rows = data.members.map((m) => [
|
||||
m.member_id,
|
||||
m.name,
|
||||
m.email,
|
||||
m.membership_type,
|
||||
m.status,
|
||||
m.dues_status,
|
||||
m.annual_dues.toFixed(2),
|
||||
m.last_payment_date || 'Never',
|
||||
m.current_due_date || 'N/A',
|
||||
m.days_overdue?.toString() || '0'
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
|
||||
|
||||
return {
|
||||
csv,
|
||||
filename: `dues-members-${new Date().toISOString().split('T')[0]}.csv`
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Export payments report as CSV
|
||||
*/
|
||||
exportPayments: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const data = await getDuesReportData();
|
||||
|
||||
// Generate CSV
|
||||
const headers = [
|
||||
'Member ID',
|
||||
'Member Name',
|
||||
'Amount',
|
||||
'Payment Date',
|
||||
'Payment Method',
|
||||
'Reference',
|
||||
'Recorded By'
|
||||
];
|
||||
|
||||
const rows = data.payments.map((p) => [
|
||||
p.member_id,
|
||||
p.member_name,
|
||||
p.amount.toFixed(2),
|
||||
p.payment_date,
|
||||
p.payment_method,
|
||||
p.reference || '',
|
||||
p.recorded_by || ''
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
|
||||
|
||||
return {
|
||||
csv,
|
||||
filename: `dues-payments-${new Date().toISOString().split('T')[0]}.csv`
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Export overdue members report as CSV
|
||||
*/
|
||||
exportOverdue: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const data = await getDuesReportData();
|
||||
|
||||
// Filter to overdue only
|
||||
const overdueMembers = data.members.filter(
|
||||
(m) => m.dues_status === 'overdue' || m.dues_status === 'never_paid'
|
||||
);
|
||||
|
||||
// Generate CSV
|
||||
const headers = [
|
||||
'Member ID',
|
||||
'Name',
|
||||
'Email',
|
||||
'Membership Type',
|
||||
'Dues Status',
|
||||
'Amount Owed',
|
||||
'Due Date',
|
||||
'Days Overdue'
|
||||
];
|
||||
|
||||
const rows = overdueMembers.map((m) => [
|
||||
m.member_id,
|
||||
m.name,
|
||||
m.email,
|
||||
m.membership_type,
|
||||
m.dues_status,
|
||||
m.annual_dues.toFixed(2),
|
||||
m.current_due_date || 'N/A',
|
||||
m.days_overdue?.toString() || 'N/A'
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
|
||||
|
||||
return {
|
||||
csv,
|
||||
filename: `dues-overdue-${new Date().toISOString().split('T')[0]}.csv`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a value for CSV
|
||||
*/
|
||||
function escapeCSV(value: string | number): string {
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
346
src/routes/(app)/board/dues/reports/+page.svelte
Normal file
346
src/routes/(app)/board/dues/reports/+page.svelte
Normal file
@@ -0,0 +1,346 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Users,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
FileSpreadsheet,
|
||||
Mail
|
||||
} from 'lucide-svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
const { analytics, effectiveness } = data;
|
||||
|
||||
let isExporting = $state<string | null>(null);
|
||||
|
||||
// Format currency
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-EU', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Download CSV from form result
|
||||
function downloadCSV(csv: string, filename: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Handle CSV download after form submission
|
||||
$effect(() => {
|
||||
if (form?.csv && form?.filename) {
|
||||
downloadCSV(form.csv, form.filename);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate chart bar heights
|
||||
function getBarHeight(amount: number, max: number): number {
|
||||
if (max === 0) return 0;
|
||||
return Math.max(4, (amount / max) * 100);
|
||||
}
|
||||
|
||||
const maxMonthlyAmount = analytics?.paymentsByMonth
|
||||
? Math.max(...analytics.paymentsByMonth.map((m: any) => m.amount), 1)
|
||||
: 1;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dues Reports | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/board/dues"
|
||||
class="flex items-center justify-center rounded-lg border border-slate-200 p-2 hover:bg-slate-50"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5 text-slate-600" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Dues Reports</h1>
|
||||
<p class="text-slate-500">Analytics and financial reporting</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Collected This Month</p>
|
||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
||||
{formatCurrency(analytics?.totalCollectedThisMonth || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-100 p-3">
|
||||
<DollarSign class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Collected This Year</p>
|
||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
||||
{formatCurrency(analytics?.totalCollectedThisYear || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-blue-100 p-3">
|
||||
<TrendingUp class="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Outstanding</p>
|
||||
<p class="mt-1 text-2xl font-bold text-red-600">
|
||||
{formatCurrency(analytics?.totalOutstanding || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-red-100 p-3">
|
||||
<AlertCircle class="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Reminders Sent</p>
|
||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
||||
{analytics?.remindersSentThisMonth || 0}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">This month</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-purple-100 p-3">
|
||||
<Mail class="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Collection Trend Chart -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<BarChart3 class="h-5 w-5 text-monaco-600" />
|
||||
Collection Trend (12 Months)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="h-64">
|
||||
{#if analytics?.paymentsByMonth && analytics.paymentsByMonth.length > 0}
|
||||
<div class="flex h-full items-end gap-2">
|
||||
{#each analytics.paymentsByMonth as month, i}
|
||||
<div class="flex flex-1 flex-col items-center">
|
||||
<div
|
||||
class="w-full rounded-t bg-monaco-500 transition-all hover:bg-monaco-600"
|
||||
style="height: {getBarHeight(month.amount, maxMonthlyAmount)}%"
|
||||
title="{month.month}: {formatCurrency(month.amount)} ({month.count} payments)"
|
||||
></div>
|
||||
<p class="mt-2 text-[10px] text-slate-500 sm:text-xs">
|
||||
{month.month.split(' ')[0].slice(0, 3)}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-slate-400">No payment data available</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Breakdown -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<PieChart class="h-5 w-5 text-monaco-600" />
|
||||
Member Status Breakdown
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if analytics?.statusBreakdown}
|
||||
{@const statusConfig = {
|
||||
current: { icon: CheckCircle2, color: 'bg-green-500', textColor: 'text-green-600', label: 'Current' },
|
||||
due_soon: { icon: Clock, color: 'bg-yellow-500', textColor: 'text-yellow-600', label: 'Due Soon' },
|
||||
overdue: { icon: AlertCircle, color: 'bg-red-500', textColor: 'text-red-600', label: 'Overdue' },
|
||||
never_paid: { icon: XCircle, color: 'bg-slate-400', textColor: 'text-slate-500', label: 'Never Paid' }
|
||||
}}
|
||||
{#each analytics.statusBreakdown as status}
|
||||
{@const config = statusConfig[status.status as keyof typeof statusConfig]}
|
||||
<div class="flex items-center gap-3">
|
||||
<svelte:component this={config.icon} class="h-5 w-5 {config.textColor}" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-700">{config.label}</span>
|
||||
<span class="text-sm text-slate-500">
|
||||
{status.count} ({status.percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
class="h-full {config.color} transition-all"
|
||||
style="width: {status.percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t border-slate-200 pt-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">Total Members</span>
|
||||
<span class="font-semibold text-slate-900">{analytics?.totalMembers || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reminder Effectiveness -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<Mail class="h-5 w-5 text-monaco-600" />
|
||||
Reminder Effectiveness
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-sm text-slate-500">Total Reminders Sent</p>
|
||||
<p class="mt-1 text-2xl font-bold text-slate-900">{effectiveness?.totalRemindersSent || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 p-4">
|
||||
<p class="text-sm text-slate-500">Paid Within 7 Days</p>
|
||||
<p class="mt-1 text-2xl font-bold text-green-600">{effectiveness?.paidWithin7Days || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-blue-50 p-4">
|
||||
<p class="text-sm text-slate-500">Paid Within 30 Days</p>
|
||||
<p class="mt-1 text-2xl font-bold text-blue-600">{effectiveness?.paidWithin30Days || 0}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-purple-50 p-4">
|
||||
<p class="text-sm text-slate-500">Effectiveness Rate</p>
|
||||
<p class="mt-1 text-2xl font-bold text-purple-600">
|
||||
{(effectiveness?.effectivenessRate || 0).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-4">
|
||||
<h2 class="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<Download class="h-5 w-5 text-monaco-600" />
|
||||
Export Reports
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Download detailed reports as CSV files</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/exportMembers"
|
||||
use:enhance={() => {
|
||||
isExporting = 'members';
|
||||
return async ({ update }) => {
|
||||
isExporting = null;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isExporting === 'members'}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{#if isExporting === 'members'}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-slate-400 border-t-transparent"></div>
|
||||
{:else}
|
||||
<FileSpreadsheet class="h-4 w-4" />
|
||||
{/if}
|
||||
All Members Report
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/exportPayments"
|
||||
use:enhance={() => {
|
||||
isExporting = 'payments';
|
||||
return async ({ update }) => {
|
||||
isExporting = null;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isExporting === 'payments'}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{#if isExporting === 'payments'}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-slate-400 border-t-transparent"></div>
|
||||
{:else}
|
||||
<FileSpreadsheet class="h-4 w-4" />
|
||||
{/if}
|
||||
Payment History
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/exportOverdue"
|
||||
use:enhance={() => {
|
||||
isExporting = 'overdue';
|
||||
return async ({ update }) => {
|
||||
isExporting = null;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isExporting === 'overdue'}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{#if isExporting === 'overdue'}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-red-400 border-t-transparent"></div>
|
||||
{:else}
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
{/if}
|
||||
Overdue Members
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
161
src/routes/(app)/board/events/+page.server.ts
Normal file
161
src/routes/(app)/board/events/+page.server.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const statusFilter = url.searchParams.get('status') || 'all';
|
||||
|
||||
// Load events with counts
|
||||
let query = locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.order('start_datetime', { ascending: true });
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
query = query.eq('status', statusFilter);
|
||||
}
|
||||
|
||||
const { data: events } = await query;
|
||||
|
||||
// Load event types
|
||||
const { data: eventTypes } = await locals.supabase
|
||||
.from('event_types')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
// Calculate stats
|
||||
const now = new Date();
|
||||
const stats = {
|
||||
total: events?.length || 0,
|
||||
upcoming: events?.filter((e: any) => new Date(e.start_datetime) > now && e.status === 'published').length || 0,
|
||||
draft: events?.filter((e: any) => e.status === 'draft').length || 0,
|
||||
past: events?.filter((e: any) => new Date(e.end_datetime) < now).length || 0
|
||||
};
|
||||
|
||||
return {
|
||||
events: events || [],
|
||||
eventTypes: eventTypes || [],
|
||||
stats,
|
||||
filters: {
|
||||
status: statusFilter
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to create events' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const eventTypeId = formData.get('event_type_id') as string;
|
||||
const startDate = formData.get('start_date') as string;
|
||||
const startTime = formData.get('start_time') as string;
|
||||
const endDate = formData.get('end_date') as string;
|
||||
const endTime = formData.get('end_time') as string;
|
||||
const location = formData.get('location') as string;
|
||||
const maxAttendees = formData.get('max_attendees') as string;
|
||||
const maxGuests = formData.get('max_guests_per_member') as string;
|
||||
const isPaid = formData.get('is_paid') === 'true';
|
||||
const memberPrice = formData.get('member_price') as string;
|
||||
const nonMemberPrice = formData.get('non_member_price') as string;
|
||||
const visibility = formData.get('visibility') as string;
|
||||
const status = formData.get('status') as string;
|
||||
|
||||
// Validation
|
||||
if (!title || !startDate || !startTime || !endDate || !endTime) {
|
||||
return fail(400, { error: 'Title, start date/time, and end date/time are required' });
|
||||
}
|
||||
|
||||
// Construct datetime strings
|
||||
const startDatetime = `${startDate}T${startTime}:00`;
|
||||
const endDatetime = `${endDate}T${endTime}:00`;
|
||||
|
||||
// Validate end is after start
|
||||
if (new Date(endDatetime) <= new Date(startDatetime)) {
|
||||
return fail(400, { error: 'End date/time must be after start date/time' });
|
||||
}
|
||||
|
||||
const { error: insertError } = await locals.supabase.from('events').insert({
|
||||
title,
|
||||
description: description || null,
|
||||
event_type_id: eventTypeId || null,
|
||||
start_datetime: startDatetime,
|
||||
end_datetime: endDatetime,
|
||||
location: location || null,
|
||||
max_attendees: maxAttendees ? parseInt(maxAttendees) : null,
|
||||
max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1,
|
||||
is_paid: isPaid,
|
||||
member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0,
|
||||
non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0,
|
||||
visibility: visibility || 'members',
|
||||
status: status || 'published',
|
||||
created_by: member.id
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Event creation error:', insertError);
|
||||
return fail(500, { error: 'Failed to create event. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Event created successfully!' };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can delete events' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const eventId = formData.get('event_id') as string;
|
||||
|
||||
if (!eventId) {
|
||||
return fail(400, { error: 'Event ID is required' });
|
||||
}
|
||||
|
||||
const { error: deleteError } = await locals.supabase.from('events').delete().eq('id', eventId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Event deletion error:', deleteError);
|
||||
return fail(500, { error: 'Failed to delete event. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Event deleted successfully!' };
|
||||
},
|
||||
|
||||
updateStatus: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to update events' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const eventId = formData.get('event_id') as string;
|
||||
const status = formData.get('status') as string;
|
||||
|
||||
if (!eventId || !status) {
|
||||
return fail(400, { error: 'Event ID and status are required' });
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('events')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', eventId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Event status update error:', updateError);
|
||||
return fail(500, { error: 'Failed to update event status. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Event status updated!' };
|
||||
}
|
||||
};
|
||||
580
src/routes/(app)/board/events/+page.svelte
Normal file
580
src/routes/(app)/board/events/+page.svelte
Normal file
@@ -0,0 +1,580 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Calendar,
|
||||
Plus,
|
||||
Search,
|
||||
Clock,
|
||||
MapPin,
|
||||
Users,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
X,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { DatePicker } from '$lib/components/ui';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data, form } = $props();
|
||||
let events = $derived(data.events);
|
||||
let eventTypes = $derived(data.eventTypes);
|
||||
let stats = $derived(data.stats);
|
||||
let filters = $derived(data.filters);
|
||||
|
||||
let statusFilter = $state(data.filters.status);
|
||||
let showCreateModal = $state(false);
|
||||
let isSubmitting = $state(false);
|
||||
let isPaid = $state(false);
|
||||
|
||||
// Date picker state
|
||||
const todayDate = today(getLocalTimeZone());
|
||||
let startDate = $state<CalendarDate | undefined>(todayDate);
|
||||
let endDate = $state<CalendarDate | undefined>(todayDate);
|
||||
|
||||
function updateFilters(newFilters: Record<string, string>) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(newFilters)) {
|
||||
if (value && value !== 'all') {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
goto(`?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Format time
|
||||
function formatTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Get status info
|
||||
function getStatusInfo(status: string) {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100', label: 'Published' };
|
||||
case 'draft':
|
||||
return { icon: FileText, color: 'text-slate-500', bg: 'bg-slate-100', label: 'Draft' };
|
||||
case 'cancelled':
|
||||
return { icon: XCircle, color: 'text-red-600', bg: 'bg-red-100', label: 'Cancelled' };
|
||||
case 'completed':
|
||||
return { icon: CheckCircle2, color: 'text-blue-600', bg: 'bg-blue-100', label: 'Completed' };
|
||||
default:
|
||||
return { icon: AlertCircle, color: 'text-slate-500', bg: 'bg-slate-100', label: status };
|
||||
}
|
||||
}
|
||||
|
||||
// Get visibility label
|
||||
function getVisibilityLabel(visibility: string) {
|
||||
switch (visibility) {
|
||||
case 'public':
|
||||
return 'Public';
|
||||
case 'members':
|
||||
return 'Members Only';
|
||||
case 'board':
|
||||
return 'Board Only';
|
||||
case 'admin':
|
||||
return 'Admin Only';
|
||||
default:
|
||||
return visibility;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if event is in past
|
||||
function isPast(endDatetime: string): boolean {
|
||||
return new Date(endDatetime) < new Date();
|
||||
}
|
||||
|
||||
// Default times
|
||||
const defaultTime = '18:00';
|
||||
const defaultEndTime = '20:00';
|
||||
|
||||
// Reset dates when modal opens
|
||||
function openCreateModal() {
|
||||
startDate = todayDate;
|
||||
endDate = todayDate;
|
||||
isPaid = false;
|
||||
showCreateModal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Event Management | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Event Management</h1>
|
||||
<p class="text-slate-500">Create and manage association events</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 font-medium text-white hover:bg-monaco-700"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<Calendar class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
|
||||
<p class="text-xs text-slate-500">Total Events</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.upcoming}</p>
|
||||
<p class="text-xs text-slate-500">Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<FileText class="h-5 w-5 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.draft}</p>
|
||||
<p class="text-xs text-slate-500">Drafts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2">
|
||||
<Clock class="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.past}</p>
|
||||
<p class="text-xs text-slate-500">Past Events</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-4">
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={() => updateFilters({ status: statusFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Events</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||
{form.success}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Events List -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
{#if events.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Calendar class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No events found</h3>
|
||||
<p class="mt-1 text-slate-500">
|
||||
{filters.status !== 'all'
|
||||
? 'Try adjusting your filters.'
|
||||
: 'Create your first event to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Event
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Date & Time
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Attendees
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Visibility
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each events as event}
|
||||
{@const statusInfo = getStatusInfo(event.status)}
|
||||
{@const eventPast = isPast(event.end_datetime)}
|
||||
<tr
|
||||
class="hover:bg-slate-50 cursor-pointer {eventPast ? 'opacity-60' : ''}"
|
||||
onclick={(e) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
if ((e.target as HTMLElement).closest('.actions-cell')) return;
|
||||
goto(`/events/${event.id}`);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
goto(`/events/${event.id}`);
|
||||
}
|
||||
}}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{event.title}</p>
|
||||
{#if event.event_type_name}
|
||||
<span
|
||||
class="inline-block mt-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style="background-color: {event.event_type_color}20; color: {event.event_type_color}"
|
||||
>
|
||||
{event.event_type_name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">
|
||||
<p class="text-slate-900">{formatDate(event.start_datetime)}</p>
|
||||
<p class="text-slate-500">
|
||||
{formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<Users class="h-4 w-4" />
|
||||
{event.total_attendees || 0}
|
||||
{#if event.max_attendees}
|
||||
/ {event.max_attendees}
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.waitlist_count > 0}
|
||||
<p class="text-xs text-yellow-600">{event.waitlist_count} on waitlist</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-slate-600">
|
||||
{getVisibilityLabel(event.visibility)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium {statusInfo.bg} {statusInfo.color}"
|
||||
>
|
||||
<svelte:component this={statusInfo.icon} class="h-3.5 w-3.5" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell px-6 py-4 text-right" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex justify-end gap-2">
|
||||
<a
|
||||
href="/events/{event.id}"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
title="View"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/board/events/{event.id}/attendees"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-monaco-100 hover:text-monaco-600"
|
||||
title="Manage Attendees"
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/board/events/{event.id}/edit"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
</a>
|
||||
{#if event.status === 'draft'}
|
||||
<form method="POST" action="?/updateStatus" class="inline">
|
||||
<input type="hidden" name="event_id" value={event.id} />
|
||||
<input type="hidden" name="status" value="published" />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-green-100 hover:text-green-600"
|
||||
title="Publish"
|
||||
>
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Event Modal -->
|
||||
{#if showCreateModal}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
|
||||
<div class="flex min-h-full items-start justify-center p-4 sm:items-center sm:p-6">
|
||||
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Create New Event</h2>
|
||||
<button onclick={() => (showCreateModal = false)} class="rounded p-1 hover:bg-slate-100">
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update, result }) => {
|
||||
isSubmitting = false;
|
||||
if (result.type === 'success') {
|
||||
showCreateModal = false;
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<Label for="title">Event Title *</Label>
|
||||
<Input type="text" id="title" name="title" required class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="event_type_id">Event Type</Label>
|
||||
<select
|
||||
id="event_type_id"
|
||||
name="event_type_id"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
{#each eventTypes as type}
|
||||
<option value={type.id}>{type.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="location">Location</Label>
|
||||
<Input type="text" id="location" name="location" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Start Date *</Label>
|
||||
<div class="mt-1">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
placeholder="Select start date"
|
||||
name="start_date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="start_time">Start Time *</Label>
|
||||
<Input type="time" id="start_time" name="start_time" value={defaultTime} required class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>End Date *</Label>
|
||||
<div class="mt-1">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
minValue={startDate}
|
||||
placeholder="Select end date"
|
||||
name="end_date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="end_time">End Time *</Label>
|
||||
<Input type="time" id="end_time" name="end_time" value={defaultEndTime} required class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="max_attendees">Max Attendees</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="max_attendees"
|
||||
name="max_attendees"
|
||||
placeholder="Unlimited"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="max_guests_per_member">Max Guests per Member</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="max_guests_per_member"
|
||||
name="max_guests_per_member"
|
||||
value="1"
|
||||
min="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_paid"
|
||||
value="true"
|
||||
bind:checked={isPaid}
|
||||
class="rounded border-slate-300"
|
||||
/>
|
||||
<span class="text-sm font-medium text-slate-700">This is a paid event</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if isPaid}
|
||||
<div>
|
||||
<Label for="member_price">Member Price (€)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="member_price"
|
||||
name="member_price"
|
||||
step="0.01"
|
||||
value="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="non_member_price">Non-Member Price (€)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="non_member_price"
|
||||
name="non_member_price"
|
||||
step="0.01"
|
||||
value="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Label for="visibility">Visibility</Label>
|
||||
<select
|
||||
id="visibility"
|
||||
name="visibility"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="members">Members Only</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="board">Board Only</option>
|
||||
<option value="admin">Admin Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateModal = false)}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
Creating...
|
||||
{:else}
|
||||
<Plus class="h-4 w-4" />
|
||||
Create Event
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
449
src/routes/(app)/board/events/[id]/attendees/+page.server.ts
Normal file
449
src/routes/(app)/board/events/[id]/attendees/+page.server.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { fail, error } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
import { sendEmail } from '$lib/server/email';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
// Fetch the event
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
throw error(404, 'Event not found');
|
||||
}
|
||||
|
||||
// Fetch all RSVPs with member details
|
||||
// Note: Using explicit foreign key reference because event_rsvps has two FKs to members (member_id, checked_in_by)
|
||||
const { data: rsvps, error: rsvpError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select(`
|
||||
*,
|
||||
member:members!event_rsvps_member_id_fkey(
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone,
|
||||
member_id
|
||||
)
|
||||
`)
|
||||
.eq('event_id', params.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (rsvpError) {
|
||||
console.error('RSVP fetch error:', rsvpError);
|
||||
}
|
||||
|
||||
// Fetch public RSVPs (non-members)
|
||||
const { data: publicRsvps } = await locals.supabase
|
||||
.from('event_rsvps_public')
|
||||
.select('*')
|
||||
.eq('event_id', params.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
// Calculate stats
|
||||
const memberRsvps = rsvps || [];
|
||||
const nonMemberRsvps = publicRsvps || [];
|
||||
|
||||
const stats = {
|
||||
confirmed: memberRsvps.filter(r => r.status === 'confirmed').length +
|
||||
nonMemberRsvps.filter(r => r.status === 'confirmed').length,
|
||||
confirmedGuests: memberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0) +
|
||||
nonMemberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0),
|
||||
waitlist: memberRsvps.filter(r => r.status === 'waitlist').length +
|
||||
nonMemberRsvps.filter(r => r.status === 'waitlist').length,
|
||||
maybe: memberRsvps.filter(r => r.status === 'maybe').length,
|
||||
declined: memberRsvps.filter(r => r.status === 'declined').length +
|
||||
nonMemberRsvps.filter(r => r.status === 'declined').length,
|
||||
attended: memberRsvps.filter(r => r.attended).length +
|
||||
nonMemberRsvps.filter(r => r.attended).length,
|
||||
totalMembers: memberRsvps.length,
|
||||
totalNonMembers: nonMemberRsvps.length
|
||||
};
|
||||
|
||||
stats.confirmedGuests; // Suppress unused warning
|
||||
|
||||
// Get all members for invitation feature
|
||||
const { data: allMembers } = await supabaseAdmin
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email, member_id')
|
||||
.order('first_name', { ascending: true });
|
||||
|
||||
// Filter out members who have already RSVPed
|
||||
const rsvpedMemberIds = new Set(memberRsvps.map(r => r.member_id));
|
||||
const uninvitedMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id));
|
||||
|
||||
return {
|
||||
event,
|
||||
memberRsvps,
|
||||
publicRsvps: nonMemberRsvps,
|
||||
stats,
|
||||
uninvitedMembers
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
checkIn: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to check in attendees' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
const attended = formData.get('attended') === 'true';
|
||||
|
||||
if (!rsvpId) {
|
||||
return fail(400, { error: 'RSVP ID is required' });
|
||||
}
|
||||
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
attended,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Only set checked_in fields if checking in
|
||||
if (attended) {
|
||||
updateData.checked_in_at = new Date().toISOString();
|
||||
if (!isPublic) {
|
||||
updateData.checked_in_by = member.id;
|
||||
}
|
||||
} else {
|
||||
updateData.checked_in_at = null;
|
||||
if (!isPublic) {
|
||||
updateData.checked_in_by = null;
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from(table)
|
||||
.update(updateData)
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Check-in error:', updateError);
|
||||
return fail(500, { error: 'Failed to update attendance. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: attended ? 'Checked in successfully!' : 'Check-in removed.' };
|
||||
},
|
||||
|
||||
updateRsvpStatus: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to update RSVPs' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
const status = formData.get('status') as string;
|
||||
|
||||
if (!rsvpId || !status) {
|
||||
return fail(400, { error: 'RSVP ID and status are required' });
|
||||
}
|
||||
|
||||
const validStatuses = ['confirmed', 'declined', 'maybe', 'waitlist', 'cancelled'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return fail(400, { error: 'Invalid status' });
|
||||
}
|
||||
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from(table)
|
||||
.update({
|
||||
status,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('RSVP status update error:', updateError);
|
||||
return fail(500, { error: 'Failed to update RSVP status. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'RSVP status updated!' };
|
||||
},
|
||||
|
||||
promoteFromWaitlist: async ({ request, locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to manage waitlist' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
|
||||
if (!rsvpId) {
|
||||
return fail(400, { error: 'RSVP ID is required' });
|
||||
}
|
||||
|
||||
// Get event to check capacity
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
return fail(404, { error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Get the RSVP to promote
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
const { data: rsvp } = await locals.supabase
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', rsvpId)
|
||||
.single();
|
||||
|
||||
if (!rsvp) {
|
||||
return fail(404, { error: 'RSVP not found' });
|
||||
}
|
||||
|
||||
if (rsvp.status !== 'waitlist') {
|
||||
return fail(400, { error: 'Only waitlisted RSVPs can be promoted' });
|
||||
}
|
||||
|
||||
// Check capacity
|
||||
const spotsNeeded = 1 + (rsvp.guest_count || 0);
|
||||
if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) {
|
||||
return fail(400, { error: 'Not enough capacity for this attendee and their guests' });
|
||||
}
|
||||
|
||||
// Promote to confirmed
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from(table)
|
||||
.update({
|
||||
status: 'confirmed',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Promotion error:', updateError);
|
||||
return fail(500, { error: 'Failed to promote from waitlist. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Successfully promoted from waitlist!' };
|
||||
},
|
||||
|
||||
deleteRsvp: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to delete RSVPs' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
|
||||
if (!rsvpId) {
|
||||
return fail(400, { error: 'RSVP ID is required' });
|
||||
}
|
||||
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
|
||||
const { error: deleteError } = await locals.supabase
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Delete RSVP error:', deleteError);
|
||||
return fail(500, { error: 'Failed to delete RSVP. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'RSVP removed successfully!' };
|
||||
},
|
||||
|
||||
markAsPaid: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to manage payments' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
|
||||
if (!rsvpId) {
|
||||
return fail(400, { error: 'RSVP ID is required' });
|
||||
}
|
||||
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from(table)
|
||||
.update({
|
||||
payment_status: 'paid',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Mark as paid error:', updateError);
|
||||
return fail(500, { error: 'Failed to update payment status. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Payment marked as received!' };
|
||||
},
|
||||
|
||||
inviteMembers: async ({ request, locals, params, url }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to invite members' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const memberIds = formData.getAll('member_ids') as string[];
|
||||
|
||||
if (!memberIds || memberIds.length === 0) {
|
||||
return fail(400, { error: 'Please select at least one member to invite' });
|
||||
}
|
||||
|
||||
// Get the event details
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
return fail(404, { error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Get the selected members
|
||||
const { data: members } = await supabaseAdmin
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email')
|
||||
.in('id', memberIds);
|
||||
|
||||
if (!members || members.length === 0) {
|
||||
return fail(400, { error: 'No valid members found' });
|
||||
}
|
||||
|
||||
// Prepare email content
|
||||
const baseUrl = url.origin;
|
||||
const eventUrl = `${baseUrl}/events/${event.id}`;
|
||||
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
||||
|
||||
// Format event date and time
|
||||
const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// Send invitations to each member
|
||||
for (const invitee of members) {
|
||||
const emailResult = await sendEmail({
|
||||
to: invitee.email,
|
||||
subject: `You're Invited: ${event.title}`,
|
||||
html: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(30, 41, 59, 0.85) 50%, rgba(127, 29, 29, 0.8) 100%); background-color: #0f172a;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<!-- Logo Section -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom: 30px;">
|
||||
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||
</div>
|
||||
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Main Content Card -->
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px;">You're Invited!</h2>
|
||||
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">Dear ${invitee.first_name},</p>
|
||||
<p style="margin: 0 0 20px 0; color: #334155; line-height: 1.6;">We'd love for you to join us at an upcoming Monaco USA event!</p>
|
||||
|
||||
<div style="background: #f8fafc; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||
<h3 style="margin: 0 0 12px 0; color: #CE1126; font-size: 18px;">${event.title}</h3>
|
||||
<p style="margin: 0 0 8px 0; color: #334155;"><strong>Date:</strong> ${eventDate}</p>
|
||||
<p style="margin: 0 0 8px 0; color: #334155;"><strong>Time:</strong> ${eventTime}</p>
|
||||
${event.location ? `<p style="margin: 0; color: #334155;"><strong>Location:</strong> ${event.location}</p>` : ''}
|
||||
</div>
|
||||
|
||||
${event.description ? `<p style="margin: 0 0 20px 0; color: #334155; line-height: 1.6;">${event.description.substring(0, 200)}${event.description.length > 200 ? '...' : ''}</p>` : ''}
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${eventUrl}" style="display: inline-block; background: #CE1126; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">RSVP Now</a>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px; text-align: center;">Click the button above to view the event details and RSVP.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 24px;">
|
||||
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
recipientId: invitee.id,
|
||||
recipientName: `${invitee.first_name} ${invitee.last_name}`,
|
||||
emailType: 'event_invitation',
|
||||
sentBy: member.id
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.error(`Failed to send invitation to ${invitee.email}:`, emailResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
return { success: `Successfully sent ${successCount} invitation${successCount !== 1 ? 's' : ''}!` };
|
||||
} else if (successCount === 0) {
|
||||
return fail(500, { error: 'Failed to send all invitations. Please check your email settings.' });
|
||||
} else {
|
||||
return { success: `Sent ${successCount} invitation${successCount !== 1 ? 's' : ''}, but ${failCount} failed.` };
|
||||
}
|
||||
}
|
||||
};
|
||||
919
src/routes/(app)/board/events/[id]/attendees/+page.svelte
Normal file
919
src/routes/(app)/board/events/[id]/attendees/+page.svelte
Normal file
@@ -0,0 +1,919 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Users,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
UserCheck,
|
||||
UserX,
|
||||
MoreVertical,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Loader2,
|
||||
Send,
|
||||
Search
|
||||
} from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
|
||||
let { data, form } = $props();
|
||||
let event = $derived(data.event);
|
||||
let memberRsvps = $derived(data.memberRsvps);
|
||||
let publicRsvps = $derived(data.publicRsvps);
|
||||
let stats = $derived(data.stats);
|
||||
let uninvitedMembers = $derived(data.uninvitedMembers || []);
|
||||
|
||||
// Calculate pending payments count
|
||||
let pendingPaymentsCount = $derived(
|
||||
memberRsvps.filter((r: any) => r.payment_status === 'pending').length +
|
||||
publicRsvps.filter((r: any) => r.payment_status === 'pending').length
|
||||
);
|
||||
|
||||
let loading = $state(false);
|
||||
let statusFilter = $state('all');
|
||||
let expandedRsvp = $state<string | null>(null);
|
||||
|
||||
// Invite members modal state
|
||||
let showInviteModal = $state(false);
|
||||
let inviteLoading = $state(false);
|
||||
let selectedMembers = $state<Set<string>>(new Set());
|
||||
let inviteSearchQuery = $state('');
|
||||
|
||||
// Filter uninvited members based on search
|
||||
let filteredUninvitedMembers = $derived(
|
||||
uninvitedMembers.filter((m: any) => {
|
||||
if (!inviteSearchQuery) return true;
|
||||
const search = inviteSearchQuery.toLowerCase();
|
||||
return (
|
||||
m.first_name?.toLowerCase().includes(search) ||
|
||||
m.last_name?.toLowerCase().includes(search) ||
|
||||
m.email?.toLowerCase().includes(search) ||
|
||||
m.member_id?.toLowerCase().includes(search)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Toggle member selection
|
||||
function toggleMemberSelection(memberId: string) {
|
||||
const newSet = new Set(selectedMembers);
|
||||
if (newSet.has(memberId)) {
|
||||
newSet.delete(memberId);
|
||||
} else {
|
||||
newSet.add(memberId);
|
||||
}
|
||||
selectedMembers = newSet;
|
||||
}
|
||||
|
||||
// Select/deselect all visible members
|
||||
function toggleSelectAll() {
|
||||
if (selectedMembers.size === filteredUninvitedMembers.length) {
|
||||
selectedMembers = new Set();
|
||||
} else {
|
||||
selectedMembers = new Set(filteredUninvitedMembers.map((m: any) => m.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Reset invite modal
|
||||
function closeInviteModal() {
|
||||
showInviteModal = false;
|
||||
selectedMembers = new Set();
|
||||
inviteSearchQuery = '';
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
// Filter RSVPs based on status
|
||||
function filterRsvps(rsvps: any[], filter: string) {
|
||||
if (filter === 'all') return rsvps;
|
||||
if (filter === 'attended') return rsvps.filter(r => r.attended);
|
||||
if (filter === 'not_attended') return rsvps.filter(r => !r.attended && r.status === 'confirmed');
|
||||
if (filter === 'pending_payment') return rsvps.filter(r => r.payment_status === 'pending');
|
||||
if (filter === 'paid') return rsvps.filter(r => r.payment_status === 'paid');
|
||||
return rsvps.filter(r => r.status === filter);
|
||||
}
|
||||
|
||||
// Get status badge color
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-700';
|
||||
case 'waitlist':
|
||||
return 'bg-yellow-100 text-yellow-700';
|
||||
case 'maybe':
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
case 'declined':
|
||||
case 'cancelled':
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
}
|
||||
|
||||
// Get payment status badge
|
||||
function getPaymentStatusBadge(paymentStatus: string, isPaidEvent: boolean) {
|
||||
if (!isPaidEvent || paymentStatus === 'not_required') return null;
|
||||
switch (paymentStatus) {
|
||||
case 'pending':
|
||||
return { color: 'bg-amber-100 text-amber-700', label: 'Payment Pending', icon: Loader2 };
|
||||
case 'paid':
|
||||
return { color: 'bg-green-100 text-green-700', label: 'Paid', icon: Check };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle row expansion
|
||||
function toggleExpand(id: string) {
|
||||
expandedRsvp = expandedRsvp === id ? null : id;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Reset loading when form changes
|
||||
if (form) {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Attendees: {event?.title || 'Event'} | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<a
|
||||
href="/board/events"
|
||||
class="mb-2 inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-monaco-600"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to events
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Attendees</h1>
|
||||
{#if event}
|
||||
<p class="mt-1 text-slate-600">{event.title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (showInviteModal = true)}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
Invite Members
|
||||
</button>
|
||||
<a
|
||||
href={`/board/events/${event?.id}/edit`}
|
||||
class="inline-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"
|
||||
>
|
||||
Edit Event
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<FormMessage type="error" message={form.error} />
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<FormMessage type="success" message={form.success} />
|
||||
{/if}
|
||||
|
||||
{#if event}
|
||||
<!-- Event Summary -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-slate-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-4 w-4 text-monaco-600" />
|
||||
<span>{formatDate(event.start_datetime)}</span>
|
||||
<span>{formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}</span>
|
||||
</div>
|
||||
{#if event.location}
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-4 w-4 text-monaco-600" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<Users class="h-4 w-4 text-monaco-600" />
|
||||
<span>{event.total_attendees}{event.max_attendees ? ` / ${event.max_attendees}` : ''} attendees</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2">
|
||||
<UserCheck class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.confirmed}</p>
|
||||
<p class="text-sm text-slate-500">Confirmed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if event?.is_paid}
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2">
|
||||
<CreditCard class="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{pendingPaymentsCount}</p>
|
||||
<p class="text-sm text-slate-500">Pending Payment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-yellow-100 p-2">
|
||||
<Clock class="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.waitlist}</p>
|
||||
<p class="text-sm text-slate-500">Waitlist</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2">
|
||||
<Clock class="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.maybe}</p>
|
||||
<p class="text-sm text-slate-500">Maybe</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<UserX class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.declined}</p>
|
||||
<p class="text-sm text-slate-500">Declined</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-monaco-100 p-2">
|
||||
<Check class="h-5 w-5 text-monaco-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.attended}</p>
|
||||
<p class="text-sm text-slate-500">Checked In</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'waitlist', label: 'Waitlist' },
|
||||
{ value: 'maybe', label: 'Maybe' },
|
||||
{ value: 'attended', label: 'Checked In' },
|
||||
{ value: 'not_attended', label: 'Not Checked In' },
|
||||
...(event?.is_paid ? [
|
||||
{ value: 'pending_payment', label: 'Pending Payment' },
|
||||
{ value: 'paid', label: 'Paid' }
|
||||
] : [])
|
||||
] as filter}
|
||||
<button
|
||||
onclick={() => statusFilter = filter.value}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {statusFilter === filter.value
|
||||
? 'bg-monaco-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'}"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Member RSVPs -->
|
||||
{#if memberRsvps.length > 0}
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-3">
|
||||
<h2 class="font-semibold text-slate-900">Members ({stats.totalMembers})</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each filterRsvps(memberRsvps, statusFilter) as rsvp}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Check-in Toggle -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/checkIn"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="false" />
|
||||
<input type="hidden" name="attended" value={rsvp.attended ? 'false' : 'true'} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {rsvp.attended
|
||||
? 'bg-monaco-600 text-white hover:bg-monaco-700'
|
||||
: 'border-2 border-slate-300 text-slate-400 hover:border-monaco-500 hover:text-monaco-500'}"
|
||||
title={rsvp.attended ? 'Mark as not attended' : 'Mark as attended'}
|
||||
>
|
||||
{#if rsvp.attended}
|
||||
<Check class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Member Info -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-medium text-slate-900 truncate">
|
||||
{rsvp.member?.first_name} {rsvp.member?.last_name}
|
||||
</p>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {getStatusColor(rsvp.status)}">
|
||||
{rsvp.status}
|
||||
</span>
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Payment Pending
|
||||
</span>
|
||||
{:else if event?.is_paid && rsvp.payment_status === 'paid'}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
<DollarSign class="h-3 w-3" />
|
||||
Paid
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">{rsvp.member?.member_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if rsvp.guest_count > 0}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">
|
||||
+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Mark as Paid Button (visible when pending) -->
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/markAsPaid"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="false" />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex items-center gap-1 rounded-lg border border-green-300 bg-green-50 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-100 disabled:opacity-50"
|
||||
title="Mark payment as received"
|
||||
>
|
||||
<DollarSign class="h-3.5 w-3.5" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Expand/Collapse -->
|
||||
<button
|
||||
onclick={() => toggleExpand(rsvp.id)}
|
||||
class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
{#if expandedRsvp === rsvp.id}
|
||||
<ChevronUp class="h-5 w-5" />
|
||||
{:else}
|
||||
<ChevronDown class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
{#if expandedRsvp === rsvp.id}
|
||||
<div class="mt-4 ml-11 space-y-3 border-l-2 border-slate-100 pl-4">
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
{#if rsvp.member?.email}
|
||||
<a href={`mailto:${rsvp.member.email}`} class="flex items-center gap-1 text-slate-600 hover:text-monaco-600">
|
||||
<Mail class="h-4 w-4" />
|
||||
{rsvp.member.email}
|
||||
</a>
|
||||
{/if}
|
||||
{#if rsvp.member?.phone}
|
||||
<a href={`tel:${rsvp.member.phone}`} class="flex items-center gap-1 text-slate-600 hover:text-monaco-600">
|
||||
<Phone class="h-4 w-4" />
|
||||
{rsvp.member.phone}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payment Info -->
|
||||
{#if event?.is_paid && rsvp.payment_amount}
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-slate-600">Amount: <strong>€{rsvp.payment_amount.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/markAsPaid"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="false" />
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading} class="border-green-300 text-green-700 hover:bg-green-50">
|
||||
<DollarSign class="mr-1 h-3.5 w-3.5" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if rsvp.status === 'waitlist'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/promoteFromWaitlist"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="false" />
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading}>
|
||||
<UserCheck class="mr-1 h-3.5 w-3.5" />
|
||||
Promote to Confirmed
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteRsvp"
|
||||
use:enhance={() => {
|
||||
if (!confirm('Remove this RSVP? This cannot be undone.')) {
|
||||
return async () => {};
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="false" />
|
||||
<Button type="submit" variant="ghost" size="sm" disabled={loading} class="text-red-600 hover:bg-red-50">
|
||||
<X class="mr-1 h-3.5 w-3.5" />
|
||||
Remove RSVP
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Non-Member RSVPs -->
|
||||
{#if publicRsvps.length > 0}
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-3">
|
||||
<h2 class="font-semibold text-slate-900">Non-Members ({stats.totalNonMembers})</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each filterRsvps(publicRsvps, statusFilter) as rsvp}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Check-in Toggle -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/checkIn"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="true" />
|
||||
<input type="hidden" name="attended" value={rsvp.attended ? 'false' : 'true'} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full transition-colors {rsvp.attended
|
||||
? 'bg-monaco-600 text-white hover:bg-monaco-700'
|
||||
: 'border-2 border-slate-300 text-slate-400 hover:border-monaco-500 hover:text-monaco-500'}"
|
||||
title={rsvp.attended ? 'Mark as not attended' : 'Mark as attended'}
|
||||
>
|
||||
{#if rsvp.attended}
|
||||
<Check class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Non-Member Info -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-medium text-slate-900 truncate">
|
||||
{rsvp.full_name}
|
||||
</p>
|
||||
<span class="rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
|
||||
Guest
|
||||
</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {getStatusColor(rsvp.status)}">
|
||||
{rsvp.status}
|
||||
</span>
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Payment Pending
|
||||
</span>
|
||||
{:else if event?.is_paid && rsvp.payment_status === 'paid'}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
<DollarSign class="h-3 w-3" />
|
||||
Paid
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">{rsvp.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if rsvp.guest_count > 0}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">
|
||||
+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Mark as Paid Button (visible when pending) -->
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/markAsPaid"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="true" />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex items-center gap-1 rounded-lg border border-green-300 bg-green-50 px-2 py-1 text-xs font-medium text-green-700 hover:bg-green-100 disabled:opacity-50"
|
||||
title="Mark payment as received"
|
||||
>
|
||||
<DollarSign class="h-3.5 w-3.5" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Expand/Collapse -->
|
||||
<button
|
||||
onclick={() => toggleExpand(`public-${rsvp.id}`)}
|
||||
class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
{#if expandedRsvp === `public-${rsvp.id}`}
|
||||
<ChevronUp class="h-5 w-5" />
|
||||
{:else}
|
||||
<ChevronDown class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
{#if expandedRsvp === `public-${rsvp.id}`}
|
||||
<div class="mt-4 ml-11 space-y-3 border-l-2 border-slate-100 pl-4">
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<a href={`mailto:${rsvp.email}`} class="flex items-center gap-1 text-slate-600 hover:text-monaco-600">
|
||||
<Mail class="h-4 w-4" />
|
||||
{rsvp.email}
|
||||
</a>
|
||||
{#if rsvp.phone}
|
||||
<a href={`tel:${rsvp.phone}`} class="flex items-center gap-1 text-slate-600 hover:text-monaco-600">
|
||||
<Phone class="h-4 w-4" />
|
||||
{rsvp.phone}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Payment Info -->
|
||||
{#if event?.is_paid && rsvp.payment_amount}
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-slate-600">Amount: <strong>€{rsvp.payment_amount.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if event?.is_paid && rsvp.payment_status === 'pending'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/markAsPaid"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="true" />
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading} class="border-green-300 text-green-700 hover:bg-green-50">
|
||||
<DollarSign class="mr-1 h-3.5 w-3.5" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if rsvp.status === 'waitlist'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/promoteFromWaitlist"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="true" />
|
||||
<Button type="submit" variant="outline" size="sm" disabled={loading}>
|
||||
<UserCheck class="mr-1 h-3.5 w-3.5" />
|
||||
Promote to Confirmed
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteRsvp"
|
||||
use:enhance={() => {
|
||||
if (!confirm('Remove this RSVP? This cannot be undone.')) {
|
||||
return async () => {};
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value="true" />
|
||||
<Button type="submit" variant="ghost" size="sm" disabled={loading} class="text-red-600 hover:bg-red-50">
|
||||
<X class="mr-1 h-3.5 w-3.5" />
|
||||
Remove RSVP
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if memberRsvps.length === 0 && publicRsvps.length === 0}
|
||||
<div class="glass-card p-12 text-center">
|
||||
<Users class="mx-auto h-12 w-12 text-slate-300" />
|
||||
<h3 class="mt-4 text-lg font-medium text-slate-900">No RSVPs yet</h3>
|
||||
<p class="mt-2 text-slate-500">No one has RSVP'd to this event yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="glass-card p-12 text-center">
|
||||
<p class="text-slate-600">Event not found.</p>
|
||||
<a href="/board/events" class="mt-4 inline-block text-monaco-600 hover:underline">
|
||||
View all events
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Invite Members Modal -->
|
||||
{#if showInviteModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-lg p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-monaco-600">
|
||||
<Send class="h-6 w-6" />
|
||||
<h3 class="text-lg font-semibold text-slate-900">Invite Members to Event</h3>
|
||||
</div>
|
||||
<button
|
||||
onclick={closeInviteModal}
|
||||
class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-slate-600">
|
||||
Select members to send an email invitation with a link to RSVP for "{event?.title}".
|
||||
</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative mb-4">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search members..."
|
||||
bind:value={inviteSearchQuery}
|
||||
class="h-10 w-full rounded-lg border border-slate-200 bg-white pl-9 pr-4 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if uninvitedMembers.length === 0}
|
||||
<div class="rounded-lg bg-slate-50 p-6 text-center">
|
||||
<Users class="mx-auto h-8 w-8 text-slate-300" />
|
||||
<p class="mt-2 text-sm text-slate-600">All members have already been invited or RSVPed.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Select All -->
|
||||
<div class="mb-2 flex items-center justify-between text-sm">
|
||||
<button
|
||||
onclick={toggleSelectAll}
|
||||
class="font-medium text-monaco-600 hover:text-monaco-700"
|
||||
>
|
||||
{selectedMembers.size === filteredUninvitedMembers.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<span class="text-slate-500">
|
||||
{selectedMembers.size} of {filteredUninvitedMembers.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Member List -->
|
||||
<div class="max-h-64 overflow-y-auto rounded-lg border border-slate-200">
|
||||
{#each filteredUninvitedMembers as member (member.id)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-3 border-b border-slate-100 px-4 py-3 hover:bg-slate-50 last:border-b-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMembers.has(member.id)}
|
||||
onchange={() => toggleMemberSelection(member.id)}
|
||||
class="h-4 w-4 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-slate-900 truncate">
|
||||
{member.first_name} {member.last_name}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500 truncate">{member.email}</p>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">{member.member_id}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="p-4 text-center text-sm text-slate-500">
|
||||
No members match your search.
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
onclick={closeInviteModal}
|
||||
disabled={inviteLoading}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/inviteMembers"
|
||||
use:enhance={() => {
|
||||
inviteLoading = true;
|
||||
return async ({ update, result }) => {
|
||||
inviteLoading = false;
|
||||
if (result.type === 'success') {
|
||||
closeInviteModal();
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="flex-1"
|
||||
>
|
||||
{#each Array.from(selectedMembers) as memberId}
|
||||
<input type="hidden" name="member_ids" value={memberId} />
|
||||
{/each}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteLoading || selectedMembers.size === 0}
|
||||
class="w-full rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if inviteLoading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</span>
|
||||
{:else}
|
||||
Send {selectedMembers.size} Invitation{selectedMembers.size !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
124
src/routes/(app)/board/events/[id]/edit/+page.server.ts
Normal file
124
src/routes/(app)/board/events/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { fail, error, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
// Fetch the event
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
throw error(404, 'Event not found');
|
||||
}
|
||||
|
||||
// Load event types
|
||||
const { data: eventTypes } = await locals.supabase
|
||||
.from('event_types')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
return {
|
||||
event,
|
||||
eventTypes: eventTypes || []
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'You do not have permission to edit events' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const eventTypeId = formData.get('event_type_id') as string;
|
||||
const startDate = formData.get('start_date') as string;
|
||||
const startTime = formData.get('start_time') as string;
|
||||
const endDate = formData.get('end_date') as string;
|
||||
const endTime = formData.get('end_time') as string;
|
||||
const location = formData.get('location') as string;
|
||||
const locationUrl = formData.get('location_url') as string;
|
||||
const maxAttendees = formData.get('max_attendees') as string;
|
||||
const maxGuests = formData.get('max_guests_per_member') as string;
|
||||
const isPaid = formData.get('is_paid') === 'true';
|
||||
const memberPrice = formData.get('member_price') as string;
|
||||
const nonMemberPrice = formData.get('non_member_price') as string;
|
||||
const visibility = formData.get('visibility') as string;
|
||||
const status = formData.get('status') as string;
|
||||
|
||||
// Validation
|
||||
if (!title || !startDate || !startTime || !endDate || !endTime) {
|
||||
return fail(400, { error: 'Title, start date/time, and end date/time are required' });
|
||||
}
|
||||
|
||||
// Construct datetime strings
|
||||
const startDatetime = `${startDate}T${startTime}:00`;
|
||||
const endDatetime = `${endDate}T${endTime}:00`;
|
||||
|
||||
// Validate end is after start
|
||||
if (new Date(endDatetime) <= new Date(startDatetime)) {
|
||||
return fail(400, { error: 'End date/time must be after start date/time' });
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('events')
|
||||
.update({
|
||||
title,
|
||||
description: description || null,
|
||||
event_type_id: eventTypeId || null,
|
||||
start_datetime: startDatetime,
|
||||
end_datetime: endDatetime,
|
||||
location: location || null,
|
||||
location_url: locationUrl || null,
|
||||
max_attendees: maxAttendees ? parseInt(maxAttendees) : null,
|
||||
max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1,
|
||||
is_paid: isPaid,
|
||||
member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0,
|
||||
non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0,
|
||||
visibility: visibility || 'members',
|
||||
status: status || 'published',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', params.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Event update error:', updateError);
|
||||
return fail(500, { error: 'Failed to update event. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Event updated successfully!' };
|
||||
},
|
||||
|
||||
delete: async ({ locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
return fail(403, { error: 'Only admins can delete events' });
|
||||
}
|
||||
|
||||
const { error: deleteError } = await locals.supabase
|
||||
.from('events')
|
||||
.delete()
|
||||
.eq('id', params.id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Event deletion error:', deleteError);
|
||||
return fail(500, { error: 'Failed to delete event. Please try again.' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/board/events?deleted=true');
|
||||
}
|
||||
};
|
||||
416
src/routes/(app)/board/events/[id]/edit/+page.svelte
Normal file
416
src/routes/(app)/board/events/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Save, Trash2, Calendar, MapPin, Users, DollarSign, Eye, X } from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { DatePicker } from '$lib/components/ui';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
|
||||
let { data, form } = $props();
|
||||
const { event, eventTypes, member } = data;
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
// Parse existing dates into CalendarDate
|
||||
const startDateJS = event.start_datetime ? new Date(event.start_datetime) : new Date();
|
||||
const endDateJS = event.end_datetime ? new Date(event.end_datetime) : new Date();
|
||||
|
||||
let startDate = $state<CalendarDate | undefined>(
|
||||
new CalendarDate(startDateJS.getFullYear(), startDateJS.getMonth() + 1, startDateJS.getDate())
|
||||
);
|
||||
let endDate = $state<CalendarDate | undefined>(
|
||||
new CalendarDate(endDateJS.getFullYear(), endDateJS.getMonth() + 1, endDateJS.getDate())
|
||||
);
|
||||
|
||||
let isPaid = $state(event.is_paid || false);
|
||||
|
||||
// Format time for input
|
||||
function formatTimeForInput(date: Date): string {
|
||||
return date.toTimeString().slice(0, 5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit Event | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<!-- Back button -->
|
||||
<a
|
||||
href="/board/events"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-monaco-600"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to events
|
||||
</a>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-900">Edit Event</h1>
|
||||
<p class="text-slate-500">Update event details and settings</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-6 rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||
{form.success}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<Calendar class="h-5 w-5 text-monaco-600" />
|
||||
Event Details
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<Label for="title">Event Title *</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={event.title}
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<Label for="description">Description</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||
>{event.description || ''}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="event_type_id">Event Type</Label>
|
||||
<select
|
||||
id="event_type_id"
|
||||
name="event_type_id"
|
||||
value={event.event_type_id || ''}
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
{#each eventTypes as type}
|
||||
<option value={type.id}>{type.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="status">Status</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={event.status}
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date & Time -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Date & Time</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Start Date *</Label>
|
||||
<div class="mt-1">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
placeholder="Select start date"
|
||||
name="start_date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="start_time">Start Time *</Label>
|
||||
<Input
|
||||
type="time"
|
||||
id="start_time"
|
||||
name="start_time"
|
||||
value={formatTimeForInput(startDateJS)}
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>End Date *</Label>
|
||||
<div class="mt-1">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
minValue={startDate}
|
||||
placeholder="Select end date"
|
||||
name="end_date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="end_time">End Time *</Label>
|
||||
<Input
|
||||
type="time"
|
||||
id="end_time"
|
||||
name="end_time"
|
||||
value={formatTimeForInput(endDateJS)}
|
||||
required
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<MapPin class="h-5 w-5 text-monaco-600" />
|
||||
Location
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label for="location">Location</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={event.location || ''}
|
||||
placeholder="e.g., Hotel Hermitage, Monte Carlo"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="location_url">Map Link (optional)</Label>
|
||||
<Input
|
||||
type="url"
|
||||
id="location_url"
|
||||
name="location_url"
|
||||
value={event.location_url || ''}
|
||||
placeholder="https://maps.google.com/..."
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capacity -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<Users class="h-5 w-5 text-monaco-600" />
|
||||
Capacity
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label for="max_attendees">Max Attendees</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="max_attendees"
|
||||
name="max_attendees"
|
||||
value={event.max_attendees || ''}
|
||||
placeholder="Unlimited"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">Leave empty for unlimited</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="max_guests_per_member">Max Guests per Member</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="max_guests_per_member"
|
||||
name="max_guests_per_member"
|
||||
value={event.max_guests_per_member || 1}
|
||||
min="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<DollarSign class="h-5 w-5 text-monaco-600" />
|
||||
Pricing
|
||||
</h2>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_paid"
|
||||
value="true"
|
||||
bind:checked={isPaid}
|
||||
class="rounded border-slate-300"
|
||||
/>
|
||||
<span class="text-sm font-medium text-slate-700">This is a paid event</span>
|
||||
</label>
|
||||
|
||||
{#if isPaid}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label for="member_price">Member Price (EUR)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="member_price"
|
||||
name="member_price"
|
||||
value={event.member_price || 0}
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="non_member_price">Non-Member Price (EUR)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="non_member_price"
|
||||
name="non_member_price"
|
||||
value={event.non_member_price || 0}
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<Eye class="h-5 w-5 text-monaco-600" />
|
||||
Visibility
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<Label for="visibility">Who can see this event?</Label>
|
||||
<select
|
||||
id="visibility"
|
||||
name="visibility"
|
||||
value={event.visibility}
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="members">Members Only</option>
|
||||
<option value="public">Public (Anyone)</option>
|
||||
<option value="board">Board Only</option>
|
||||
<option value="admin">Admin Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-4 border-t border-slate-200 pt-6 sm:flex-row sm:justify-between">
|
||||
{#if member?.role === 'admin'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="flex items-center justify-center gap-2 rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete Event
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/board/events"
|
||||
class="flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
class="flex items-center justify-center gap-2 rounded-lg bg-monaco-600 px-6 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{#if isSubmitting}
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="h-4 w-4" />
|
||||
Save Changes
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="glass-card w-full max-w-md p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-red-600">Delete Event</h3>
|
||||
<button onclick={() => (showDeleteConfirm = false)} class="rounded p-1 hover:bg-slate-100">
|
||||
<X class="h-5 w-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-slate-600">
|
||||
Are you sure you want to delete <strong>{event.title}</strong>? This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<p class="mb-6 text-sm text-slate-500">
|
||||
All RSVPs and associated data will also be deleted.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<form method="POST" action="?/delete" class="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete Event
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
281
src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
Normal file
281
src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { fail, error, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
// Only board and admin can access roll call
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
throw redirect(303, `/board/events/${params.id}`);
|
||||
}
|
||||
|
||||
// Fetch the event
|
||||
const { data: event, error: eventError } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (eventError || !event) {
|
||||
throw error(404, 'Event not found');
|
||||
}
|
||||
|
||||
// Fetch all RSVPs with member details (only confirmed status for roll call)
|
||||
const { data: rsvps, error: rsvpError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select(`
|
||||
*,
|
||||
member:members!event_rsvps_member_id_fkey(
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone,
|
||||
member_id,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('event_id', params.id)
|
||||
.in('status', ['confirmed', 'waitlist'])
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (rsvpError) {
|
||||
console.error('RSVP fetch error:', rsvpError);
|
||||
}
|
||||
|
||||
// Fetch public RSVPs (non-members with confirmed status)
|
||||
const { data: publicRsvps, error: publicError } = await locals.supabase
|
||||
.from('event_rsvps_public')
|
||||
.select('*')
|
||||
.eq('event_id', params.id)
|
||||
.in('status', ['confirmed', 'waitlist'])
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (publicError) {
|
||||
console.error('Public RSVP fetch error:', publicError);
|
||||
}
|
||||
|
||||
// Calculate stats
|
||||
const allMemberRsvps = rsvps || [];
|
||||
const allPublicRsvps = publicRsvps || [];
|
||||
const allRsvps = [...allMemberRsvps, ...allPublicRsvps];
|
||||
|
||||
const stats = {
|
||||
total: allRsvps.length,
|
||||
confirmed: allRsvps.filter(r => r.status === 'confirmed').length,
|
||||
checkedIn: allRsvps.filter(r => r.attended).length,
|
||||
waitlist: allRsvps.filter(r => r.status === 'waitlist').length
|
||||
};
|
||||
|
||||
// Get all members for walk-in feature
|
||||
const { data: allMembers } = await supabaseAdmin
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email, member_id, avatar_url')
|
||||
.order('first_name', { ascending: true });
|
||||
|
||||
// Filter out members who have already RSVPed
|
||||
const rsvpedMemberIds = new Set(allMemberRsvps.map(r => r.member_id));
|
||||
const availableMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id));
|
||||
|
||||
return {
|
||||
event,
|
||||
memberRsvps: allMemberRsvps,
|
||||
publicRsvps: allPublicRsvps,
|
||||
stats,
|
||||
availableMembers
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
checkIn: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Permission denied' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpId = formData.get('rsvp_id') as string;
|
||||
const isPublic = formData.get('is_public') === 'true';
|
||||
const attended = formData.get('attended') === 'true';
|
||||
|
||||
if (!rsvpId) {
|
||||
return fail(400, { error: 'RSVP ID required' });
|
||||
}
|
||||
|
||||
const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
attended,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (attended) {
|
||||
updateData.checked_in_at = new Date().toISOString();
|
||||
if (!isPublic) {
|
||||
updateData.checked_in_by = member.id;
|
||||
}
|
||||
} else {
|
||||
updateData.checked_in_at = null;
|
||||
if (!isPublic) {
|
||||
updateData.checked_in_by = null;
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from(table)
|
||||
.update(updateData)
|
||||
.eq('id', rsvpId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Check-in error:', updateError);
|
||||
return fail(500, { error: 'Check-in failed' });
|
||||
}
|
||||
|
||||
return { success: true, attended };
|
||||
},
|
||||
|
||||
addWalkIn: async ({ request, locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Permission denied' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const memberId = formData.get('member_id') as string;
|
||||
const guestName = formData.get('guest_name') as string;
|
||||
const guestEmail = formData.get('guest_email') as string;
|
||||
|
||||
const eventId = params.id;
|
||||
|
||||
// If it's an existing member
|
||||
if (memberId) {
|
||||
// Check if member already has an RSVP
|
||||
const { data: existing } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select('id')
|
||||
.eq('event_id', eventId)
|
||||
.eq('member_id', memberId)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
return fail(400, { error: 'Member already has an RSVP for this event' });
|
||||
}
|
||||
|
||||
// Create RSVP with immediate check-in
|
||||
const { error: insertError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
member_id: memberId,
|
||||
status: 'confirmed',
|
||||
attended: true,
|
||||
checked_in_at: new Date().toISOString(),
|
||||
checked_in_by: member.id,
|
||||
rsvp_source: 'walk_in',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Walk-in RSVP error:', insertError);
|
||||
return fail(500, { error: 'Failed to add walk-in' });
|
||||
}
|
||||
|
||||
return { success: true, message: 'Member added as walk-in' };
|
||||
}
|
||||
|
||||
// If it's a non-member guest
|
||||
if (guestName) {
|
||||
const { error: insertError } = await locals.supabase
|
||||
.from('event_rsvps_public')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
full_name: guestName,
|
||||
email: guestEmail || null,
|
||||
status: 'confirmed',
|
||||
attended: true,
|
||||
checked_in_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
console.error('Walk-in guest error:', insertError);
|
||||
return fail(500, { error: 'Failed to add guest' });
|
||||
}
|
||||
|
||||
return { success: true, message: 'Guest added as walk-in' };
|
||||
}
|
||||
|
||||
return fail(400, { error: 'Please provide a member or guest information' });
|
||||
},
|
||||
|
||||
bulkCheckIn: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member || (member.role !== 'board' && member.role !== 'admin')) {
|
||||
return fail(403, { error: 'Permission denied' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const rsvpIdsString = formData.get('rsvp_ids') as string;
|
||||
const isPublicString = formData.get('is_public_ids') as string;
|
||||
const attended = formData.get('attended') === 'true';
|
||||
|
||||
if (!rsvpIdsString) {
|
||||
return fail(400, { error: 'No RSVPs selected' });
|
||||
}
|
||||
|
||||
const rsvpIds = rsvpIdsString.split(',');
|
||||
const isPublicIds = isPublicString ? isPublicString.split(',') : [];
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
attended,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (attended) {
|
||||
updateData.checked_in_at = new Date().toISOString();
|
||||
} else {
|
||||
updateData.checked_in_at = null;
|
||||
}
|
||||
|
||||
// Update member RSVPs
|
||||
const memberRsvpIds = rsvpIds.filter(id => !isPublicIds.includes(id));
|
||||
const publicRsvpIds = rsvpIds.filter(id => isPublicIds.includes(id));
|
||||
|
||||
if (memberRsvpIds.length > 0) {
|
||||
const memberUpdateData = { ...updateData };
|
||||
if (attended) {
|
||||
memberUpdateData.checked_in_by = member.id;
|
||||
} else {
|
||||
memberUpdateData.checked_in_by = null;
|
||||
}
|
||||
|
||||
const { error: memberError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.update(memberUpdateData)
|
||||
.in('id', memberRsvpIds);
|
||||
|
||||
if (memberError) {
|
||||
console.error('Bulk member check-in error:', memberError);
|
||||
}
|
||||
}
|
||||
|
||||
if (publicRsvpIds.length > 0) {
|
||||
const { error: publicError } = await locals.supabase
|
||||
.from('event_rsvps_public')
|
||||
.update(updateData)
|
||||
.in('id', publicRsvpIds);
|
||||
|
||||
if (publicError) {
|
||||
console.error('Bulk public check-in error:', publicError);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, count: rsvpIds.length };
|
||||
}
|
||||
};
|
||||
513
src/routes/(app)/board/events/[id]/roll-call/+page.svelte
Normal file
513
src/routes/(app)/board/events/[id]/roll-call/+page.svelte
Normal file
@@ -0,0 +1,513 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Check,
|
||||
X,
|
||||
UserPlus,
|
||||
Users,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Clock,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const event = $derived(data.event);
|
||||
const memberRsvps = $derived(data.memberRsvps);
|
||||
const publicRsvps = $derived(data.publicRsvps);
|
||||
const stats = $derived(data.stats);
|
||||
const availableMembers = $derived(data.availableMembers || []);
|
||||
|
||||
// Local state
|
||||
let searchQuery = $state('');
|
||||
let filter = $state<'all' | 'confirmed' | 'checked' | 'unchecked'>('all');
|
||||
let showWalkInModal = $state(false);
|
||||
let walkInTab = $state<'member' | 'guest'>('member');
|
||||
let walkInMemberSearch = $state('');
|
||||
let selectedWalkInMember = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
let guestEmail = $state('');
|
||||
let walkInLoading = $state(false);
|
||||
|
||||
// Combine and filter RSVPs
|
||||
const allRsvps = $derived([
|
||||
...memberRsvps.map((r: any) => ({ ...r, type: 'member' })),
|
||||
...publicRsvps.map((r: any) => ({ ...r, type: 'public' }))
|
||||
]);
|
||||
|
||||
const filteredRsvps = $derived(() => {
|
||||
let result = allRsvps;
|
||||
|
||||
// Apply status filter
|
||||
if (filter === 'confirmed') {
|
||||
result = result.filter(r => r.status === 'confirmed');
|
||||
} else if (filter === 'checked') {
|
||||
result = result.filter(r => r.attended);
|
||||
} else if (filter === 'unchecked') {
|
||||
result = result.filter(r => !r.attended && r.status === 'confirmed');
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(r => {
|
||||
if (r.type === 'member') {
|
||||
const name = `${r.member?.first_name} ${r.member?.last_name}`.toLowerCase();
|
||||
const memberId = r.member?.member_id?.toLowerCase() || '';
|
||||
return name.includes(query) || memberId.includes(query);
|
||||
} else {
|
||||
return r.full_name?.toLowerCase().includes(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: unchecked first, then checked
|
||||
return result.sort((a, b) => {
|
||||
if (a.attended && !b.attended) return 1;
|
||||
if (!a.attended && b.attended) return -1;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
// Filter available members for walk-in
|
||||
const filteredWalkInMembers = $derived(
|
||||
walkInMemberSearch.trim()
|
||||
? availableMembers.filter((m: any) => {
|
||||
const query = walkInMemberSearch.toLowerCase();
|
||||
const name = `${m.first_name} ${m.last_name}`.toLowerCase();
|
||||
return name.includes(query) || m.member_id?.toLowerCase().includes(query);
|
||||
})
|
||||
: availableMembers.slice(0, 20)
|
||||
);
|
||||
|
||||
function getInitials(firstName: string, lastName: string): string {
|
||||
return `${firstName?.charAt(0) || ''}${lastName?.charAt(0) || ''}`.toUpperCase();
|
||||
}
|
||||
|
||||
function formatEventDate(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function closeWalkInModal() {
|
||||
showWalkInModal = false;
|
||||
walkInTab = 'member';
|
||||
walkInMemberSearch = '';
|
||||
selectedWalkInMember = null;
|
||||
guestName = '';
|
||||
guestEmail = '';
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Roll Call: {event?.title || 'Event'} | Monaco USA</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-slate-50">
|
||||
<!-- Header - Sticky -->
|
||||
<header class="sticky top-0 z-20 bg-white shadow-sm">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href="/board/events/{event?.id}/attendees"
|
||||
class="flex items-center gap-2 text-slate-600 active:text-monaco-600"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
<span class="sr-only sm:not-sr-only">Back</span>
|
||||
</a>
|
||||
<div class="text-center">
|
||||
<h1 class="text-lg font-bold text-slate-900 truncate max-w-[200px] sm:max-w-none">
|
||||
{event?.title || 'Roll Call'}
|
||||
</h1>
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
{#if event?.start_datetime}
|
||||
<span>{formatEventDate(event.start_datetime)}</span>
|
||||
{/if}
|
||||
{#if event?.location}
|
||||
<span>• {event.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleRefresh}
|
||||
class="rounded-full p-2 text-slate-500 active:bg-slate-100"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="flex items-center justify-center gap-6 border-t border-slate-100 bg-slate-50 px-4 py-2">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-monaco-600">{stats.checkedIn}</p>
|
||||
<p class="text-xs text-slate-500">Checked In</p>
|
||||
</div>
|
||||
<div class="h-8 w-px bg-slate-200"></div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-slate-700">{stats.confirmed}</p>
|
||||
<p class="text-xs text-slate-500">Confirmed</p>
|
||||
</div>
|
||||
{#if stats.waitlist > 0}
|
||||
<div class="h-8 w-px bg-slate-200"></div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-amber-600">{stats.waitlist}</p>
|
||||
<p class="text-xs text-slate-500">Waitlist</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search Bar - Sticky -->
|
||||
<div class="border-t border-slate-100 bg-white px-4 py-3">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search attendees..."
|
||||
class="h-12 w-full rounded-xl border border-slate-200 bg-slate-50 pl-11 pr-4 text-base placeholder:text-slate-400 focus:border-monaco-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => searchQuery = ''}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex gap-2 overflow-x-auto border-t border-slate-100 bg-white px-4 py-2 scrollbar-none">
|
||||
{#each [
|
||||
{ value: 'all', label: 'All', count: stats.total },
|
||||
{ value: 'confirmed', label: 'Confirmed', count: stats.confirmed },
|
||||
{ value: 'checked', label: 'Here', count: stats.checkedIn },
|
||||
{ value: 'unchecked', label: 'Not Here', count: stats.confirmed - stats.checkedIn }
|
||||
] as tab}
|
||||
<button
|
||||
onclick={() => filter = tab.value as typeof filter}
|
||||
class="flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-all
|
||||
{filter === tab.value
|
||||
? 'bg-monaco-600 text-white shadow-sm'
|
||||
: 'bg-slate-100 text-slate-600 active:bg-slate-200'}"
|
||||
>
|
||||
{tab.label}
|
||||
<span class="rounded-full px-1.5 py-0.5 text-xs {filter === tab.value ? 'bg-white/20' : 'bg-slate-200'}">
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Attendee List -->
|
||||
<main class="flex-1 overflow-y-auto px-4 py-3 pb-24">
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filteredRsvps().length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Users class="h-12 w-12 text-slate-300" />
|
||||
<p class="mt-3 text-slate-600">
|
||||
{searchQuery ? 'No attendees match your search' : 'No attendees to show'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each filteredRsvps() as rsvp (rsvp.id)}
|
||||
{@const isMember = rsvp.type === 'member'}
|
||||
{@const name = isMember ? `${rsvp.member?.first_name} ${rsvp.member?.last_name}` : rsvp.full_name}
|
||||
{@const memberId = isMember ? rsvp.member?.member_id : null}
|
||||
{@const avatarUrl = isMember ? rsvp.member?.avatar_url : null}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/checkIn"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rsvp_id" value={rsvp.id} />
|
||||
<input type="hidden" name="is_public" value={!isMember} />
|
||||
<input type="hidden" name="attended" value={!rsvp.attended} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-4 rounded-xl border bg-white p-4 transition-all active:scale-[0.98]
|
||||
{rsvp.attended
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-slate-200 hover:border-slate-300'}"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
{#if avatarUrl}
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
class="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-lg font-medium text-slate-600">
|
||||
{getInitials(isMember ? rsvp.member?.first_name : rsvp.full_name?.split(' ')[0] || '', isMember ? rsvp.member?.last_name : rsvp.full_name?.split(' ')[1] || '')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if rsvp.status === 'waitlist'}
|
||||
<div class="absolute -bottom-1 -right-1 rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-bold text-white">
|
||||
WL
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate font-medium text-slate-900">{name}</p>
|
||||
{#if !isMember}
|
||||
<span class="shrink-0 rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-700">
|
||||
Guest
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if memberId}
|
||||
<p class="text-sm text-slate-500">{memberId}</p>
|
||||
{:else if !isMember && rsvp.email}
|
||||
<p class="truncate text-sm text-slate-500">{rsvp.email}</p>
|
||||
{/if}
|
||||
{#if rsvp.guest_count > 0}
|
||||
<p class="text-xs text-slate-400">+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Check-in Button -->
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl text-lg font-bold transition-all
|
||||
{rsvp.attended
|
||||
? 'bg-green-500 text-white shadow-sm'
|
||||
: 'border-2 border-dashed border-slate-300 text-slate-400'}"
|
||||
>
|
||||
{#if rsvp.attended}
|
||||
<Check class="h-7 w-7" strokeWidth={3} />
|
||||
{:else}
|
||||
<span class="text-2xl">+</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Floating Action Button - Walk-in -->
|
||||
<button
|
||||
onclick={() => showWalkInModal = true}
|
||||
class="fixed bottom-24 right-4 z-10 flex h-14 items-center gap-2 rounded-full bg-monaco-600 px-5 text-white shadow-lg active:bg-monaco-700 lg:bottom-6"
|
||||
>
|
||||
<UserPlus class="h-5 w-5" />
|
||||
<span class="font-medium">Walk-in</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Walk-in Modal -->
|
||||
{#if showWalkInModal}
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center sm:p-4">
|
||||
<div class="w-full max-w-md rounded-t-2xl bg-white sm:rounded-2xl" style="max-height: 90vh;">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Add Walk-in</h2>
|
||||
<button
|
||||
onclick={closeWalkInModal}
|
||||
class="rounded-full p-2 text-slate-400 hover:bg-slate-100"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-100">
|
||||
<button
|
||||
onclick={() => walkInTab = 'member'}
|
||||
class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors
|
||||
{walkInTab === 'member'
|
||||
? 'border-monaco-600 text-monaco-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'}"
|
||||
>
|
||||
Existing Member
|
||||
</button>
|
||||
<button
|
||||
onclick={() => walkInTab = 'guest'}
|
||||
class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors
|
||||
{walkInTab === 'guest'
|
||||
? 'border-monaco-600 text-monaco-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'}"
|
||||
>
|
||||
New Guest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="overflow-y-auto p-4" style="max-height: calc(90vh - 140px);">
|
||||
{#if walkInTab === 'member'}
|
||||
<!-- Member Search -->
|
||||
<div class="relative mb-4">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={walkInMemberSearch}
|
||||
placeholder="Search members..."
|
||||
class="h-11 w-full rounded-lg border border-slate-200 pl-10 pr-4 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filteredWalkInMembers.length === 0}
|
||||
<p class="py-8 text-center text-sm text-slate-500">
|
||||
{walkInMemberSearch ? 'No members found' : 'All members have already RSVPed'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each filteredWalkInMembers as member (member.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectedWalkInMember = selectedWalkInMember === member.id ? null : member.id}
|
||||
class="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors
|
||||
{selectedWalkInMember === member.id
|
||||
? 'border-monaco-500 bg-monaco-50'
|
||||
: 'border-slate-200 hover:bg-slate-50'}"
|
||||
>
|
||||
{#if member.avatar_url}
|
||||
<img src={member.avatar_url} alt="" class="h-10 w-10 rounded-full object-cover" />
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100 text-sm font-medium text-slate-600">
|
||||
{getInitials(member.first_name, member.last_name)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-slate-900">{member.first_name} {member.last_name}</p>
|
||||
<p class="text-xs text-slate-500">{member.member_id}</p>
|
||||
</div>
|
||||
{#if selectedWalkInMember === member.id}
|
||||
<Check class="h-5 w-5 text-monaco-600" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Member Button -->
|
||||
{#if selectedWalkInMember}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addWalkIn"
|
||||
use:enhance={() => {
|
||||
walkInLoading = true;
|
||||
return async ({ update, result }) => {
|
||||
walkInLoading = false;
|
||||
if (result.type === 'success') {
|
||||
closeWalkInModal();
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
<input type="hidden" name="member_id" value={selectedWalkInMember} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={walkInLoading}
|
||||
class="w-full rounded-lg bg-monaco-600 py-3 font-medium text-white active:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{walkInLoading ? 'Adding...' : 'Add & Check In'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Guest Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addWalkIn"
|
||||
use:enhance={() => {
|
||||
walkInLoading = true;
|
||||
return async ({ update, result }) => {
|
||||
walkInLoading = false;
|
||||
if (result.type === 'success') {
|
||||
closeWalkInModal();
|
||||
await invalidateAll();
|
||||
}
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label for="guest_name" class="mb-1 block text-sm font-medium text-slate-700">
|
||||
Guest Name <span class="text-monaco-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="guest_name"
|
||||
name="guest_name"
|
||||
bind:value={guestName}
|
||||
required
|
||||
placeholder="Full name"
|
||||
class="h-11 w-full rounded-lg border border-slate-200 px-4 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="guest_email" class="mb-1 block text-sm font-medium text-slate-700">
|
||||
Email <span class="text-slate-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="guest_email"
|
||||
name="guest_email"
|
||||
bind:value={guestEmail}
|
||||
placeholder="email@example.com"
|
||||
class="h-11 w-full rounded-lg border border-slate-200 px-4 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={walkInLoading || !guestName.trim()}
|
||||
class="w-full rounded-lg bg-monaco-600 py-3 font-medium text-white active:bg-monaco-700 disabled:opacity-50"
|
||||
>
|
||||
{walkInLoading ? 'Adding...' : 'Add Guest & Check In'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
62
src/routes/(app)/board/members/+page.server.ts
Normal file
62
src/routes/(app)/board/members/+page.server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const searchQuery = url.searchParams.get('search') || '';
|
||||
const statusFilter = url.searchParams.get('status') || 'all';
|
||||
const roleFilter = url.searchParams.get('role') || 'all';
|
||||
|
||||
// Build the query
|
||||
let query = locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.order('last_name', { ascending: true });
|
||||
|
||||
// Apply filters
|
||||
if (statusFilter !== 'all') {
|
||||
query = query.eq('status_name', statusFilter);
|
||||
}
|
||||
|
||||
if (roleFilter !== 'all') {
|
||||
query = query.eq('role', roleFilter);
|
||||
}
|
||||
|
||||
const { data: members } = await query;
|
||||
|
||||
// Filter by search query in application (for name/email search)
|
||||
let filteredMembers = members || [];
|
||||
if (searchQuery) {
|
||||
const lowerSearch = searchQuery.toLowerCase();
|
||||
filteredMembers = filteredMembers.filter(
|
||||
(m: any) =>
|
||||
m.first_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.last_name?.toLowerCase().includes(lowerSearch) ||
|
||||
m.email?.toLowerCase().includes(lowerSearch) ||
|
||||
m.member_id?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
// Get membership statuses for filter dropdown
|
||||
const { data: statuses } = await locals.supabase
|
||||
.from('membership_statuses')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: members?.length || 0,
|
||||
active: members?.filter((m: any) => m.status_name === 'active').length || 0,
|
||||
pending: members?.filter((m: any) => m.status_name === 'pending').length || 0,
|
||||
inactive: members?.filter((m: any) => m.status_name === 'inactive').length || 0
|
||||
};
|
||||
|
||||
return {
|
||||
members: filteredMembers,
|
||||
statuses: statuses || [],
|
||||
stats,
|
||||
filters: {
|
||||
search: searchQuery,
|
||||
status: statusFilter,
|
||||
role: roleFilter
|
||||
}
|
||||
};
|
||||
};
|
||||
374
src/routes/(app)/board/members/+page.svelte
Normal file
374
src/routes/(app)/board/members/+page.svelte
Normal file
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Filter,
|
||||
Mail,
|
||||
Phone,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Flag,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
UserCircle,
|
||||
CreditCard,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import CountryFlag from '$lib/components/ui/CountryFlag.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { data } = $props();
|
||||
const { members, statuses, stats, filters } = data;
|
||||
|
||||
let searchQuery = $state(filters.search);
|
||||
let statusFilter = $state(filters.status);
|
||||
let roleFilter = $state(filters.role);
|
||||
let showFilters = $state(false);
|
||||
|
||||
// Debounce search
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function handleSearch(value: string) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
updateFilters({ search: value });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function updateFilters(newFilters: Record<string, string>) {
|
||||
const params = new URLSearchParams($page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(newFilters)) {
|
||||
if (value && value !== 'all') {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
goto(`?${params.toString()}`, { replaceState: true });
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Get status info
|
||||
function getStatusInfo(status: string | null) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100' };
|
||||
case 'pending':
|
||||
return { icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-100' };
|
||||
case 'inactive':
|
||||
return { icon: XCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||
case 'expired':
|
||||
return { icon: AlertCircle, color: 'text-red-600', bg: 'bg-red-100' };
|
||||
default:
|
||||
return { icon: UserCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get dues status info
|
||||
function getDuesInfo(status: string | null) {
|
||||
switch (status) {
|
||||
case 'current':
|
||||
return { color: 'text-green-600', bg: 'bg-green-100', label: 'Current' };
|
||||
case 'due_soon':
|
||||
return { color: 'text-yellow-600', bg: 'bg-yellow-100', label: 'Due Soon' };
|
||||
case 'overdue':
|
||||
return { color: 'text-red-600', bg: 'bg-red-100', label: 'Overdue' };
|
||||
case 'never_paid':
|
||||
default:
|
||||
return { color: 'text-slate-500', bg: 'bg-slate-100', label: 'Never Paid' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get role badge
|
||||
function getRoleBadge(role: string) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return { color: 'text-purple-700', bg: 'bg-purple-100', label: 'Admin' };
|
||||
case 'board':
|
||||
return { color: 'text-blue-700', bg: 'bg-blue-100', label: 'Board' };
|
||||
default:
|
||||
return { color: 'text-slate-600', bg: 'bg-slate-100', label: 'Member' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Members Directory | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Members Directory</h1>
|
||||
<p class="text-slate-500">View and manage association members</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<Users class="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
|
||||
<p class="text-xs text-slate-500">Total Members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.active}</p>
|
||||
<p class="text-xs text-slate-500">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-yellow-100 p-2">
|
||||
<Clock class="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.pending}</p>
|
||||
<p class="text-xs text-slate-500">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-slate-100 p-2">
|
||||
<XCircle class="h-5 w-5 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-900">{stats.inactive}</p>
|
||||
<p class="text-xs text-slate-500">Inactive</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="glass-card p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or member ID..."
|
||||
value={searchQuery}
|
||||
oninput={(e) => {
|
||||
searchQuery = e.currentTarget.value;
|
||||
handleSearch(e.currentTarget.value);
|
||||
}}
|
||||
class="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
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"
|
||||
>
|
||||
<Filter class="h-4 w-4" />
|
||||
Filters
|
||||
<ChevronDown class="h-4 w-4 transition-transform {showFilters ? 'rotate-180' : ''}" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<div class="mt-4 flex flex-wrap gap-4 border-t border-slate-200 pt-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-slate-500">Status</label>
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={() => updateFilters({ status: statusFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
{#each statuses as status}
|
||||
<option value={status.name}>{status.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-slate-500">Role</label>
|
||||
<select
|
||||
bind:value={roleFilter}
|
||||
onchange={() => updateFilters({ role: roleFilter })}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Members Table -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
{#if members.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<Users class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No members found</h3>
|
||||
<p class="mt-1 text-slate-500">
|
||||
{filters.search || filters.status !== 'all' || filters.role !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'Members will appear here when added.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Member
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Contact
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Dues
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Member Since
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each members as member}
|
||||
{@const statusInfo = getStatusInfo(member.status_name)}
|
||||
{@const duesInfo = getDuesInfo(member.dues_status)}
|
||||
{@const roleBadge = getRoleBadge(member.role)}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if member.avatar_url}
|
||||
<img
|
||||
src={member.avatar_url}
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
|
||||
>
|
||||
{member.first_name?.[0]}{member.last_name?.[0]}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-slate-900">
|
||||
{member.first_name} {member.last_name}
|
||||
</p>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {roleBadge.bg} {roleBadge.color}"
|
||||
>
|
||||
{roleBadge.label}
|
||||
</span>
|
||||
{#if member.nationality && member.nationality.length > 0}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each member.nationality as code}
|
||||
<CountryFlag {code} size="xs" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">{member.member_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<Mail class="h-3.5 w-3.5" />
|
||||
<a href="mailto:{member.email}" class="hover:text-monaco-600">
|
||||
{member.email}
|
||||
</a>
|
||||
</div>
|
||||
{#if member.phone}
|
||||
<div class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<Phone class="h-3.5 w-3.5" />
|
||||
{member.phone}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {statusInfo.bg} {statusInfo.color}"
|
||||
>
|
||||
<svelte:component this={statusInfo.icon} class="h-3.5 w-3.5" />
|
||||
{member.status_display_name || member.status_name || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {duesInfo.bg} {duesInfo.color}"
|
||||
>
|
||||
{duesInfo.label}
|
||||
</span>
|
||||
{#if member.current_due_date}
|
||||
<p class="text-xs text-slate-500">
|
||||
Due: {formatDate(member.current_due_date)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{formatDate(member.member_since)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a
|
||||
href="/board/members/{member.id}"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="/board/dues?member={member.id}"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-monaco-600"
|
||||
title="Manage Dues"
|
||||
>
|
||||
<CreditCard class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
141
src/routes/(app)/board/reports/+page.server.ts
Normal file
141
src/routes/(app)/board/reports/+page.server.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const reportType = url.searchParams.get('type') || 'membership';
|
||||
const year = parseInt(url.searchParams.get('year') || new Date().getFullYear().toString());
|
||||
|
||||
// Get all members with dues info
|
||||
const { data: members } = await locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('*')
|
||||
.order('last_name', { ascending: true });
|
||||
|
||||
// Get all payments for the year
|
||||
const startOfYear = new Date(year, 0, 1).toISOString();
|
||||
const endOfYear = new Date(year, 11, 31, 23, 59, 59).toISOString();
|
||||
|
||||
const { data: payments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select(`
|
||||
*,
|
||||
member:members(first_name, last_name, email, member_id)
|
||||
`)
|
||||
.gte('payment_date', startOfYear)
|
||||
.lte('payment_date', endOfYear)
|
||||
.order('payment_date', { ascending: false });
|
||||
|
||||
// Get all events for the year with attendance data
|
||||
const { data: events } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.gte('start_datetime', startOfYear)
|
||||
.lte('start_datetime', endOfYear)
|
||||
.order('start_datetime', { ascending: false });
|
||||
|
||||
// Get event RSVPs for attendance report
|
||||
const { data: rsvps } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select(`
|
||||
*,
|
||||
event:events(title, start_datetime),
|
||||
member:members(first_name, last_name, email)
|
||||
`)
|
||||
.eq('status', 'confirmed');
|
||||
|
||||
// Calculate membership statistics
|
||||
const membershipStats = {
|
||||
total: members?.length || 0,
|
||||
byStatus: {} as Record<string, number>,
|
||||
byRole: {
|
||||
admin: members?.filter(m => m.role === 'admin').length || 0,
|
||||
board: members?.filter(m => m.role === 'board').length || 0,
|
||||
member: members?.filter(m => m.role === 'member').length || 0
|
||||
},
|
||||
byDuesStatus: {
|
||||
current: members?.filter(m => m.dues_status === 'current').length || 0,
|
||||
due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0,
|
||||
overdue: members?.filter(m => m.dues_status === 'overdue').length || 0,
|
||||
never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0
|
||||
}
|
||||
};
|
||||
|
||||
// Group by status
|
||||
for (const member of members || []) {
|
||||
const status = member.status_display_name || 'Unknown';
|
||||
membershipStats.byStatus[status] = (membershipStats.byStatus[status] || 0) + 1;
|
||||
}
|
||||
|
||||
// Calculate dues collection statistics
|
||||
const duesStats = {
|
||||
totalCollected: payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0,
|
||||
paymentCount: payments?.length || 0,
|
||||
byMonth: {} as Record<string, { amount: number; count: number }>,
|
||||
byMethod: {} as Record<string, { amount: number; count: number }>
|
||||
};
|
||||
|
||||
// Group payments by month
|
||||
for (const payment of payments || []) {
|
||||
const month = new Date(payment.payment_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
if (!duesStats.byMonth[month]) {
|
||||
duesStats.byMonth[month] = { amount: 0, count: 0 };
|
||||
}
|
||||
duesStats.byMonth[month].amount += payment.amount || 0;
|
||||
duesStats.byMonth[month].count++;
|
||||
|
||||
const method = payment.payment_method || 'Unknown';
|
||||
if (!duesStats.byMethod[method]) {
|
||||
duesStats.byMethod[method] = { amount: 0, count: 0 };
|
||||
}
|
||||
duesStats.byMethod[method].amount += payment.amount || 0;
|
||||
duesStats.byMethod[method].count++;
|
||||
}
|
||||
|
||||
// Calculate event attendance statistics
|
||||
const eventStats = {
|
||||
totalEvents: events?.length || 0,
|
||||
totalAttendees: events?.reduce((sum, e) => sum + (e.total_attendees || 0), 0) || 0,
|
||||
averageAttendance: events?.length
|
||||
? Math.round((events.reduce((sum, e) => sum + (e.total_attendees || 0), 0) / events.length))
|
||||
: 0,
|
||||
byType: {} as Record<string, { count: number; attendees: number }>
|
||||
};
|
||||
|
||||
// Group events by type
|
||||
for (const event of events || []) {
|
||||
const type = event.event_type_name || 'General';
|
||||
if (!eventStats.byType[type]) {
|
||||
eventStats.byType[type] = { count: 0, attendees: 0 };
|
||||
}
|
||||
eventStats.byType[type].count++;
|
||||
eventStats.byType[type].attendees += event.total_attendees || 0;
|
||||
}
|
||||
|
||||
// Available years for dropdown
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
||||
|
||||
return {
|
||||
reportType,
|
||||
year,
|
||||
availableYears,
|
||||
members: members || [],
|
||||
payments: payments || [],
|
||||
events: events || [],
|
||||
rsvps: rsvps || [],
|
||||
membershipStats,
|
||||
duesStats,
|
||||
eventStats
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
exportCsv: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const reportType = formData.get('report_type') as string;
|
||||
const year = parseInt(formData.get('year') as string);
|
||||
|
||||
// Data will be generated client-side for CSV export
|
||||
// This action is a placeholder for server-side export if needed
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
511
src/routes/(app)/board/reports/+page.svelte
Normal file
511
src/routes/(app)/board/reports/+page.svelte
Normal file
@@ -0,0 +1,511 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Users,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
PieChart
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const {
|
||||
reportType,
|
||||
year,
|
||||
availableYears,
|
||||
members,
|
||||
payments,
|
||||
events,
|
||||
membershipStats,
|
||||
duesStats,
|
||||
eventStats
|
||||
} = data;
|
||||
|
||||
function changeReport(type: string) {
|
||||
goto(`?type=${type}&year=${year}`);
|
||||
}
|
||||
|
||||
function changeYear(newYear: number) {
|
||||
goto(`?type=${reportType}&year=${newYear}`);
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function exportToCsv(reportType: string) {
|
||||
let csvContent = '';
|
||||
let filename = '';
|
||||
|
||||
switch (reportType) {
|
||||
case 'membership':
|
||||
csvContent = 'Member ID,First Name,Last Name,Email,Status,Role,Dues Status,Member Since\n';
|
||||
for (const member of members) {
|
||||
csvContent += `"${member.member_id}","${member.first_name}","${member.last_name}","${member.email}","${member.status_display_name || ''}","${member.role}","${member.dues_status}","${member.member_since}"\n`;
|
||||
}
|
||||
filename = `membership-report-${year}.csv`;
|
||||
break;
|
||||
|
||||
case 'dues':
|
||||
csvContent = 'Date,Member,Amount,Method,Reference\n';
|
||||
for (const payment of payments) {
|
||||
csvContent += `"${payment.payment_date}","${payment.member?.first_name} ${payment.member?.last_name}","${payment.amount}","${payment.payment_method}","${payment.reference || ''}"\n`;
|
||||
}
|
||||
filename = `dues-report-${year}.csv`;
|
||||
break;
|
||||
|
||||
case 'events':
|
||||
csvContent = 'Date,Event,Type,Attendees,Max Capacity,Waitlist\n';
|
||||
for (const event of events) {
|
||||
csvContent += `"${event.start_datetime}","${event.title}","${event.event_type_name || 'General'}","${event.total_attendees}","${event.max_attendees || 'Unlimited'}","${event.waitlist_count}"\n`;
|
||||
}
|
||||
filename = `events-report-${year}.csv`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Create and download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
const reportTabs = [
|
||||
{ id: 'membership', label: 'Membership', icon: Users },
|
||||
{ id: 'dues', label: 'Dues Collection', icon: DollarSign },
|
||||
{ id: 'events', label: 'Event Attendance', icon: Calendar }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reports | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Reports</h1>
|
||||
<p class="text-slate-500">Generate and export membership, dues, and event reports</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Year Selector -->
|
||||
<select
|
||||
value={year}
|
||||
onchange={(e) => changeYear(parseInt(e.currentTarget.value))}
|
||||
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||
>
|
||||
{#each availableYears as y}
|
||||
<option value={y}>{y}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<!-- Export Button -->
|
||||
<Button variant="outline" onclick={() => exportToCsv(reportType)}>
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Type Tabs -->
|
||||
<div class="flex gap-2 border-b border-slate-200 pb-3">
|
||||
{#each reportTabs as tab}
|
||||
<button
|
||||
onclick={() => changeReport(tab.id)}
|
||||
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors {reportType === tab.id
|
||||
? 'bg-monaco-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
|
||||
>
|
||||
<tab.icon class="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Membership Report -->
|
||||
{#if reportType === 'membership'}
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Total Members</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{membershipStats.total}</p>
|
||||
</div>
|
||||
<Users class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Current Dues</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{membershipStats.byDuesStatus.current}</p>
|
||||
</div>
|
||||
<BarChart3 class="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Overdue</p>
|
||||
<p class="mt-1 text-3xl font-bold text-red-600">{membershipStats.byDuesStatus.overdue}</p>
|
||||
</div>
|
||||
<BarChart3 class="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Never Paid</p>
|
||||
<p class="mt-1 text-3xl font-bold text-amber-600">{membershipStats.byDuesStatus.never_paid}</p>
|
||||
</div>
|
||||
<PieChart class="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown Cards -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- By Role -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Members by Role</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">Administrators</span>
|
||||
<span class="font-semibold">{membershipStats.byRole.admin}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">Board Members</span>
|
||||
<span class="font-semibold">{membershipStats.byRole.board}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">Regular Members</span>
|
||||
<span class="font-semibold">{membershipStats.byRole.member}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Status -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Members by Status</h3>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(membershipStats.byStatus) as [status, count]}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">{status}</span>
|
||||
<span class="font-semibold">{count}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member List -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-3">
|
||||
<h3 class="font-semibold text-slate-900">All Members ({members.length})</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-6 py-3 font-medium">Member</th>
|
||||
<th class="px-6 py-3 font-medium">ID</th>
|
||||
<th class="px-6 py-3 font-medium">Status</th>
|
||||
<th class="px-6 py-3 font-medium">Dues</th>
|
||||
<th class="px-6 py-3 font-medium">Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each members.slice(0, 20) as member}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-3">
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{member.first_name} {member.last_name}</p>
|
||||
<p class="text-xs text-slate-500">{member.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-slate-600">{member.member_id}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style="background-color: {member.status_color}20; color: {member.status_color}">
|
||||
{member.status_display_name || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {
|
||||
member.dues_status === 'current' ? 'bg-green-100 text-green-700' :
|
||||
member.dues_status === 'due_soon' ? 'bg-amber-100 text-amber-700' :
|
||||
member.dues_status === 'overdue' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}">
|
||||
{member.dues_status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-slate-600">{formatDate(member.member_since)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if members.length > 20}
|
||||
<div class="border-t border-slate-200 bg-slate-50 px-6 py-3 text-center text-sm text-slate-500">
|
||||
Showing 20 of {members.length} members. Export CSV for full list.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dues Collection Report -->
|
||||
{#if reportType === 'dues'}
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Total Collected ({year})</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{formatCurrency(duesStats.totalCollected)}</p>
|
||||
</div>
|
||||
<DollarSign class="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Payments</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{duesStats.paymentCount}</p>
|
||||
</div>
|
||||
<FileText class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Avg. Payment</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">
|
||||
{formatCurrency(duesStats.paymentCount > 0 ? duesStats.totalCollected / duesStats.paymentCount : 0)}
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 class="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Collection Rate</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">
|
||||
{Math.round((membershipStats.byDuesStatus.current / membershipStats.total) * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
<PieChart class="h-8 w-8 text-monaco-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown Cards -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- By Month -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Collection by Month</h3>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(duesStats.byMonth).slice(0, 12) as [month, data]}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">{month}</span>
|
||||
<div class="text-right">
|
||||
<span class="font-semibold">{formatCurrency(data.amount)}</span>
|
||||
<span class="ml-2 text-sm text-slate-500">({data.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By Method -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Collection by Payment Method</h3>
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(duesStats.byMethod) as [method, data]}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600 capitalize">{method.replace('_', ' ')}</span>
|
||||
<div class="text-right">
|
||||
<span class="font-semibold">{formatCurrency(data.amount)}</span>
|
||||
<span class="ml-2 text-sm text-slate-500">({data.count})</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment List -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-3">
|
||||
<h3 class="font-semibold text-slate-900">All Payments ({payments.length})</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-6 py-3 font-medium">Date</th>
|
||||
<th class="px-6 py-3 font-medium">Member</th>
|
||||
<th class="px-6 py-3 font-medium">Amount</th>
|
||||
<th class="px-6 py-3 font-medium">Method</th>
|
||||
<th class="px-6 py-3 font-medium">Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each payments.slice(0, 20) as payment}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-3 text-slate-600">{formatDate(payment.payment_date)}</td>
|
||||
<td class="px-6 py-3">
|
||||
<p class="font-medium text-slate-900">{payment.member?.first_name} {payment.member?.last_name}</p>
|
||||
</td>
|
||||
<td class="px-6 py-3 font-semibold text-green-600">{formatCurrency(payment.amount)}</td>
|
||||
<td class="px-6 py-3 text-slate-600 capitalize">{payment.payment_method?.replace('_', ' ')}</td>
|
||||
<td class="px-6 py-3 text-slate-500">{payment.reference || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if payments.length > 20}
|
||||
<div class="border-t border-slate-200 bg-slate-50 px-6 py-3 text-center text-sm text-slate-500">
|
||||
Showing 20 of {payments.length} payments. Export CSV for full list.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Event Attendance Report -->
|
||||
{#if reportType === 'events'}
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Total Events ({year})</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{eventStats.totalEvents}</p>
|
||||
</div>
|
||||
<Calendar class="h-8 w-8 text-monaco-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Total Attendees</p>
|
||||
<p class="mt-1 text-3xl font-bold text-blue-600">{eventStats.totalAttendees}</p>
|
||||
</div>
|
||||
<Users class="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Avg. Attendance</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{eventStats.averageAttendance}</p>
|
||||
</div>
|
||||
<BarChart3 class="h-8 w-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">Event Types</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{Object.keys(eventStats.byType).length}</p>
|
||||
</div>
|
||||
<PieChart class="h-8 w-8 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown by Type -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 font-semibold text-slate-900">Attendance by Event Type</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Object.entries(eventStats.byType) as [type, data]}
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="font-medium text-slate-900">{type}</p>
|
||||
<div class="mt-2 flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">{data.count} events</span>
|
||||
<span class="font-semibold text-blue-600">{data.attendees} attendees</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event List -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 bg-slate-50 px-6 py-3">
|
||||
<h3 class="font-semibold text-slate-900">All Events ({events.length})</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-6 py-3 font-medium">Date</th>
|
||||
<th class="px-6 py-3 font-medium">Event</th>
|
||||
<th class="px-6 py-3 font-medium">Type</th>
|
||||
<th class="px-6 py-3 font-medium">Attendees</th>
|
||||
<th class="px-6 py-3 font-medium">Capacity</th>
|
||||
<th class="px-6 py-3 font-medium">Waitlist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each events.slice(0, 20) as event}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-3 text-slate-600">{formatDate(event.start_datetime)}</td>
|
||||
<td class="px-6 py-3 font-medium text-slate-900">{event.title}</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style="background-color: {event.event_type_color}20; color: {event.event_type_color}">
|
||||
{event.event_type_name || 'General'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 font-semibold text-blue-600">{event.total_attendees}</td>
|
||||
<td class="px-6 py-3 text-slate-600">{event.max_attendees || 'Unlimited'}</td>
|
||||
<td class="px-6 py-3 text-slate-600">{event.waitlist_count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if events.length > 20}
|
||||
<div class="border-t border-slate-200 bg-slate-50 px-6 py-3 text-center text-sm text-slate-500">
|
||||
Showing 20 of {events.length} events. Export CSV for full list.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
84
src/routes/(app)/dashboard/+page.server.ts
Normal file
84
src/routes/(app)/dashboard/+page.server.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Fetch upcoming events
|
||||
const { data: upcomingEvents } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.in('visibility', getVisibleLevels(member?.role))
|
||||
.eq('status', 'published')
|
||||
.gte('start_datetime', new Date().toISOString())
|
||||
.order('start_datetime', { ascending: true })
|
||||
.limit(5);
|
||||
|
||||
// Fetch stats for board/admin
|
||||
let stats = null;
|
||||
|
||||
if (member?.role === 'board' || member?.role === 'admin') {
|
||||
const isAdmin = member?.role === 'admin';
|
||||
|
||||
// Get member counts by status
|
||||
const { data: memberCounts } = await locals.supabase
|
||||
.from('members_with_dues')
|
||||
.select('status_name, dues_status');
|
||||
|
||||
const totalMembers = memberCounts?.length || 0;
|
||||
const activeMembers = memberCounts?.filter((m) => m.status_name === 'active').length || 0;
|
||||
const pendingMembers = memberCounts?.filter((m) => m.status_name === 'pending').length || 0;
|
||||
const inactiveMembers = memberCounts?.filter((m) => m.status_name === 'inactive').length || 0;
|
||||
const duesOverdue = memberCounts?.filter((m) => m.dues_status === 'overdue').length || 0;
|
||||
const duesSoon = memberCounts?.filter((m) => m.dues_status === 'due_soon').length || 0;
|
||||
|
||||
// Get upcoming events count (next 30 days)
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||
|
||||
const { count: upcomingEventsCount } = await locals.supabase
|
||||
.from('events')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('status', 'published')
|
||||
.gte('start_datetime', new Date().toISOString())
|
||||
.lte('start_datetime', thirtyDaysFromNow.toISOString());
|
||||
|
||||
// Get total dues collected this year (admin only)
|
||||
let totalDuesCollected = 0;
|
||||
if (isAdmin) {
|
||||
const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString();
|
||||
const { data: payments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select('amount')
|
||||
.gte('payment_date', startOfYear);
|
||||
|
||||
totalDuesCollected = payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
|
||||
}
|
||||
|
||||
stats = {
|
||||
totalMembers,
|
||||
activeMembers,
|
||||
pendingMembers,
|
||||
inactiveMembers,
|
||||
duesOverdue,
|
||||
duesSoon,
|
||||
upcomingEventsCount: upcomingEventsCount || 0,
|
||||
totalDuesCollected
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
upcomingEvents: upcomingEvents || [],
|
||||
stats
|
||||
};
|
||||
};
|
||||
|
||||
function getVisibleLevels(role: string | undefined): string[] {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['public', 'members', 'board', 'admin'];
|
||||
case 'board':
|
||||
return ['public', 'members', 'board'];
|
||||
default:
|
||||
return ['public', 'members'];
|
||||
}
|
||||
}
|
||||
110
src/routes/(app)/dashboard/+page.svelte
Normal file
110
src/routes/(app)/dashboard/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
WelcomeCard,
|
||||
DuesStatusCard,
|
||||
UpcomingEventsCard,
|
||||
QuickActionsCard,
|
||||
StatsCard
|
||||
} from '$lib/components/dashboard';
|
||||
import { Users, UserCheck, UserX, DollarSign, Calendar, AlertTriangle } from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const { member, upcomingEvents, stats } = data;
|
||||
const isBoard = member?.role === 'board' || member?.role === 'admin';
|
||||
const isAdmin = member?.role === 'admin';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Welcome Card -->
|
||||
{#if member}
|
||||
<WelcomeCard {member} />
|
||||
{/if}
|
||||
|
||||
<!-- Quick Actions (Mobile-first) -->
|
||||
{#if member}
|
||||
<QuickActionsCard role={member.role} />
|
||||
{/if}
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Dues Status -->
|
||||
{#if member}
|
||||
<DuesStatusCard {member} />
|
||||
{/if}
|
||||
|
||||
<!-- Upcoming Events -->
|
||||
<UpcomingEventsCard events={upcomingEvents || []} />
|
||||
</div>
|
||||
|
||||
<!-- Board Stats Section -->
|
||||
{#if isBoard && stats}
|
||||
<div class="mt-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-slate-900">Board Overview</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Members"
|
||||
value={stats.totalMembers}
|
||||
description="All registered members"
|
||||
icon={Users}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Members"
|
||||
value={stats.activeMembers}
|
||||
description="With current dues"
|
||||
icon={UserCheck}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Pending Members"
|
||||
value={stats.pendingMembers}
|
||||
description="Awaiting payment"
|
||||
icon={UserX}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Dues Overdue"
|
||||
value={stats.duesOverdue}
|
||||
description="Past due date"
|
||||
icon={AlertTriangle}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Admin Stats Section -->
|
||||
{#if isAdmin && stats}
|
||||
<div class="mt-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-slate-900">Admin Overview</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Total Dues Collected"
|
||||
value={`€${stats.totalDuesCollected?.toFixed(2) || '0.00'}`}
|
||||
description="This year"
|
||||
icon={DollarSign}
|
||||
color="green"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Upcoming Events"
|
||||
value={stats.upcomingEventsCount || 0}
|
||||
description="Next 30 days"
|
||||
icon={Calendar}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Inactive Members"
|
||||
value={stats.inactiveMembers || 0}
|
||||
description="Lapsed membership"
|
||||
icon={UserX}
|
||||
color="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
49
src/routes/(app)/documents/+page.server.ts
Normal file
49
src/routes/(app)/documents/+page.server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { isS3Enabled } from '$lib/server/storage';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Get visible visibility levels
|
||||
const visibleLevels = getVisibleLevels(member?.role);
|
||||
|
||||
// Fetch documents with all URL columns
|
||||
const { data: documents } = await locals.supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.in('visibility', visibleLevels)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = await locals.supabase
|
||||
.from('document_categories')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('sort_order', { ascending: true });
|
||||
|
||||
// Resolve active URL for each document based on current storage settings
|
||||
const s3Enabled = await isS3Enabled();
|
||||
const documentsWithActiveUrl = (documents || []).map((doc: any) => ({
|
||||
...doc,
|
||||
// Compute active URL based on storage setting
|
||||
active_url: s3Enabled
|
||||
? (doc.file_url_s3 || doc.file_path)
|
||||
: (doc.file_url_local || doc.file_path)
|
||||
}));
|
||||
|
||||
return {
|
||||
documents: documentsWithActiveUrl,
|
||||
categories: categories || []
|
||||
};
|
||||
};
|
||||
|
||||
function getVisibleLevels(role: string | undefined): string[] {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['public', 'members', 'board', 'admin'];
|
||||
case 'board':
|
||||
return ['public', 'members', 'board'];
|
||||
default:
|
||||
return ['public', 'members'];
|
||||
}
|
||||
}
|
||||
270
src/routes/(app)/documents/+page.svelte
Normal file
270
src/routes/(app)/documents/+page.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Download,
|
||||
Eye,
|
||||
Calendar,
|
||||
Search,
|
||||
Filter,
|
||||
Grid,
|
||||
List
|
||||
} from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
||||
let { data } = $props();
|
||||
const { documents, categories } = data;
|
||||
|
||||
let searchQuery = $state('');
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
let viewMode = $state<'grid' | 'list'>('grid');
|
||||
|
||||
// Filter documents
|
||||
const filteredDocuments = $derived(
|
||||
(documents || []).filter((doc: any) => {
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
doc.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = !selectedCategory || doc.category_id === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
})
|
||||
);
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Get file icon based on mime type
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType.includes('pdf')) return 'pdf';
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'doc';
|
||||
if (mimeType.includes('sheet') || mimeType.includes('excel')) return 'xls';
|
||||
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return 'ppt';
|
||||
if (mimeType.includes('image')) return 'img';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
// Get category by ID
|
||||
function getCategory(categoryId: string | null) {
|
||||
return categories?.find((c: any) => c.id === categoryId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Documents | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Documents</h1>
|
||||
<p class="text-slate-500">Meeting minutes, bylaws, and resources</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex rounded-lg border border-slate-200 bg-white p-1">
|
||||
<button
|
||||
onclick={() => (viewMode = 'grid')}
|
||||
class="rounded-md p-1.5 {viewMode === 'grid' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
|
||||
>
|
||||
<Grid class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (viewMode = 'list')}
|
||||
class="rounded-md p-1.5 {viewMode === 'list' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="space-y-4">
|
||||
<!-- Search bar - always full width -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search documents..."
|
||||
bind:value={searchQuery}
|
||||
class="h-10 w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category filters -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
onclick={() => (selectedCategory = null)}
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
|
||||
null
|
||||
? 'bg-monaco-100 text-monaco-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each categories || [] as category}
|
||||
<button
|
||||
onclick={() => (selectedCategory = category.id)}
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
|
||||
category.id
|
||||
? 'bg-monaco-100 text-monaco-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
|
||||
>
|
||||
{category.display_name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
{#if filteredDocuments.length === 0}
|
||||
<div class="glass-card flex flex-col items-center justify-center p-12 text-center">
|
||||
<FolderOpen class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No documents found</h3>
|
||||
<p class="mt-1 text-slate-500">
|
||||
{searchQuery || selectedCategory
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'Documents will appear here when added.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if viewMode === 'grid'}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredDocuments as doc}
|
||||
{@const category = getCategory(doc.category_id)}
|
||||
<div class="glass-card overflow-hidden transition-all hover:shadow-md">
|
||||
<div class="border-b border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="h-5 w-5 text-monaco-600" />
|
||||
<span class="text-xs font-medium uppercase text-slate-500">
|
||||
{getFileIcon(doc.mime_type)}
|
||||
</span>
|
||||
{#if category}
|
||||
<span class="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-600">
|
||||
{category.display_name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium text-slate-900">{doc.title}</h3>
|
||||
{#if doc.description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-slate-500">{doc.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex items-center gap-4 text-xs text-slate-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<Calendar class="h-3.5 w-3.5" />
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
<span>{formatFileSize(doc.file_size)}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href={doc.active_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
View
|
||||
</a>
|
||||
<a
|
||||
href={doc.active_url}
|
||||
download={doc.file_name}
|
||||
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-monaco-100 px-3 py-2 text-sm font-medium text-monaco-700 hover:bg-monaco-200"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- List View -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Document
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Category
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Size
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each filteredDocuments as doc}
|
||||
{@const category = getCategory(doc.category_id)}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="h-5 w-5 text-monaco-600" />
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{doc.title}</p>
|
||||
<p class="text-xs text-slate-500">{doc.file_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{category?.display_name || '-'}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{formatDate(doc.created_at)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{formatFileSize(doc.file_size)}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a
|
||||
href={doc.active_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href={doc.active_url}
|
||||
download={doc.file_name}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-monaco-600"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
31
src/routes/(app)/events/+page.server.ts
Normal file
31
src/routes/(app)/events/+page.server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Get visible events based on user role
|
||||
const visibleLevels = getVisibleLevels(member?.role);
|
||||
|
||||
const { data: events } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.in('visibility', visibleLevels)
|
||||
.eq('status', 'published')
|
||||
.gte('start_datetime', new Date().toISOString())
|
||||
.order('start_datetime', { ascending: true });
|
||||
|
||||
return {
|
||||
events: events || []
|
||||
};
|
||||
};
|
||||
|
||||
function getVisibleLevels(role: string | undefined): string[] {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['public', 'members', 'board', 'admin'];
|
||||
case 'board':
|
||||
return ['public', 'members', 'board'];
|
||||
default:
|
||||
return ['public', 'members'];
|
||||
}
|
||||
}
|
||||
340
src/routes/(app)/events/+page.svelte
Normal file
340
src/routes/(app)/events/+page.svelte
Normal file
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { Calendar, MapPin, Users, Clock, Grid, List, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { EventWithCounts } from '$lib/types/database';
|
||||
|
||||
let { data } = $props();
|
||||
const { events, member } = data;
|
||||
|
||||
let viewMode = $state<'list' | 'calendar'>('list');
|
||||
let currentMonth = $state(new Date());
|
||||
|
||||
// Format date and time
|
||||
function formatDateTime(dateStr: string): { date: string; time: string; weekday: string } {
|
||||
const date = new Date(dateStr);
|
||||
return {
|
||||
date: date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
time: date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
}),
|
||||
weekday: date.toLocaleDateString('en-US', { weekday: 'short' })
|
||||
};
|
||||
}
|
||||
|
||||
// Check if event is today
|
||||
function isToday(dateStr: string): boolean {
|
||||
const eventDate = new Date(dateStr).toDateString();
|
||||
return eventDate === new Date().toDateString();
|
||||
}
|
||||
|
||||
// Check if event is this week
|
||||
function isThisWeek(dateStr: string): boolean {
|
||||
const eventDate = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 7);
|
||||
return eventDate >= weekStart && eventDate < weekEnd;
|
||||
}
|
||||
|
||||
// Get event type color
|
||||
function getTypeColor(color: string | null): string {
|
||||
return color || '#6b7280';
|
||||
}
|
||||
|
||||
// Group events by date
|
||||
function groupEventsByDate(events: EventWithCounts[]) {
|
||||
const groups: { date: string; events: EventWithCounts[] }[] = [];
|
||||
let currentDate = '';
|
||||
|
||||
for (const event of events) {
|
||||
const eventDate = new Date(event.start_datetime).toDateString();
|
||||
if (eventDate !== currentDate) {
|
||||
currentDate = eventDate;
|
||||
groups.push({ date: eventDate, events: [event] });
|
||||
} else {
|
||||
groups[groups.length - 1].events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
const groupedEvents = $derived(groupEventsByDate(events || []));
|
||||
|
||||
// Calendar helpers
|
||||
function getDaysInMonth(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const startingDay = firstDay.getDay();
|
||||
|
||||
const days = [];
|
||||
// Add empty cells for days before the first day of the month
|
||||
for (let i = 0; i < startingDay; i++) {
|
||||
days.push(null);
|
||||
}
|
||||
// Add the days of the month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function getEventsForDate(date: Date | null) {
|
||||
if (!date) return [];
|
||||
const dateStr = date.toDateString();
|
||||
return (events || []).filter((e) => new Date(e.start_datetime).toDateString() === dateStr);
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
const calendarDays = $derived(getDaysInMonth(currentMonth));
|
||||
const monthName = $derived(
|
||||
currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Events | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Events</h1>
|
||||
<p class="text-slate-500">Upcoming events and activities</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex rounded-lg border border-slate-200 bg-white p-1">
|
||||
<button
|
||||
onclick={() => (viewMode = 'list')}
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {viewMode ===
|
||||
'list'
|
||||
? 'bg-monaco-100 text-monaco-700'
|
||||
: 'text-slate-600 hover:bg-slate-50'}"
|
||||
>
|
||||
<List class="h-4 w-4" />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (viewMode = 'calendar')}
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {viewMode ===
|
||||
'calendar'
|
||||
? 'bg-monaco-100 text-monaco-700'
|
||||
: 'text-slate-600 hover:bg-slate-50'}"
|
||||
>
|
||||
<Grid class="h-4 w-4" />
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if viewMode === 'list'}
|
||||
<!-- List View -->
|
||||
<div class="space-y-6">
|
||||
{#if groupedEvents.length === 0}
|
||||
<div class="glass-card flex flex-col items-center justify-center p-12 text-center">
|
||||
<Calendar class="mb-4 h-16 w-16 text-slate-300" />
|
||||
<h3 class="text-lg font-medium text-slate-900">No upcoming events</h3>
|
||||
<p class="mt-1 text-slate-500">Check back later for new events and activities.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each groupedEvents as group}
|
||||
{@const groupDate = new Date(group.date)}
|
||||
<div>
|
||||
<!-- Date Header -->
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-col items-center justify-center rounded-lg {isToday(
|
||||
group.date
|
||||
)
|
||||
? 'bg-monaco-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600'}"
|
||||
>
|
||||
<span class="text-xs font-medium uppercase">
|
||||
{groupDate.toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span class="text-lg font-bold">
|
||||
{groupDate.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{isToday(group.date)
|
||||
? 'Today'
|
||||
: groupDate.toLocaleDateString('en-US', { weekday: 'long' })}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
{groupDate.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events for this date -->
|
||||
<div class="space-y-3">
|
||||
{#each group.events as event}
|
||||
{@const { time } = formatDateTime(event.start_datetime)}
|
||||
<a
|
||||
href="/events/{event.id}"
|
||||
class="glass-card block p-4 transition-all hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Time column -->
|
||||
<div class="w-16 flex-shrink-0 text-right">
|
||||
<span class="text-sm font-medium text-slate-600">{time}</span>
|
||||
</div>
|
||||
|
||||
<!-- Event type indicator -->
|
||||
<div
|
||||
class="mt-1.5 h-3 w-3 flex-shrink-0 rounded-full"
|
||||
style="background-color: {getTypeColor(event.event_type_color)}"
|
||||
></div>
|
||||
|
||||
<!-- Event details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-slate-500">
|
||||
{event.event_type_name || 'Event'}
|
||||
</span>
|
||||
{#if event.is_paid}
|
||||
<span
|
||||
class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"
|
||||
>
|
||||
€{event.member_price}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="mt-1 font-semibold text-slate-900">{event.title}</h3>
|
||||
{#if event.description}
|
||||
<p class="mt-1 line-clamp-2 text-sm text-slate-500">
|
||||
{event.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500"
|
||||
>
|
||||
{#if event.location}
|
||||
<span class="flex items-center gap-1">
|
||||
<MapPin class="h-3.5 w-3.5" />
|
||||
{event.location}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1">
|
||||
<Users class="h-3.5 w-3.5" />
|
||||
{event.total_attendees}
|
||||
{event.max_attendees ? ` / ${event.max_attendees}` : ''} attending
|
||||
</span>
|
||||
{#if event.is_full}
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700"
|
||||
>
|
||||
Full
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Calendar View -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<!-- Calendar Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<button
|
||||
onclick={prevMonth}
|
||||
class="rounded-lg p-2 hover:bg-slate-100"
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft class="h-5 w-5 text-slate-600" />
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold text-slate-900">{monthName}</h2>
|
||||
<button
|
||||
onclick={nextMonth}
|
||||
class="rounded-lg p-2 hover:bg-slate-100"
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight class="h-5 w-5 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="p-4">
|
||||
<!-- Weekday headers -->
|
||||
<div class="mb-2 grid grid-cols-7 text-center text-xs font-medium text-slate-500">
|
||||
<div>Sun</div>
|
||||
<div>Mon</div>
|
||||
<div>Tue</div>
|
||||
<div>Wed</div>
|
||||
<div>Thu</div>
|
||||
<div>Fri</div>
|
||||
<div>Sat</div>
|
||||
</div>
|
||||
|
||||
<!-- Days grid -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{#each calendarDays as day}
|
||||
{@const dayEvents = getEventsForDate(day)}
|
||||
<div
|
||||
class="min-h-24 rounded-lg border border-slate-100 p-1 {day
|
||||
? 'bg-white'
|
||||
: 'bg-slate-50'}"
|
||||
>
|
||||
{#if day}
|
||||
<div
|
||||
class="mb-1 text-right text-sm {day.toDateString() === new Date().toDateString()
|
||||
? 'font-bold text-monaco-600'
|
||||
: 'text-slate-600'}"
|
||||
>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
{#each dayEvents.slice(0, 2) as event}
|
||||
<a
|
||||
href="/events/{event.id}"
|
||||
class="mb-1 block truncate rounded px-1 py-0.5 text-xs"
|
||||
style="background-color: {getTypeColor(event.event_type_color)}20; color: {getTypeColor(
|
||||
event.event_type_color
|
||||
)}"
|
||||
>
|
||||
{event.title}
|
||||
</a>
|
||||
{/each}
|
||||
{#if dayEvents.length > 2}
|
||||
<span class="text-xs text-slate-500">+{dayEvents.length - 2} more</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
314
src/routes/(app)/events/[id]/+page.server.ts
Normal file
314
src/routes/(app)/events/[id]/+page.server.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { fail, error } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { sendTemplatedEmail } from '$lib/server/email';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Fetch the event
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
throw error(404, 'Event not found');
|
||||
}
|
||||
|
||||
// Check visibility permissions
|
||||
const visibleLevels = getVisibleLevels(member?.role);
|
||||
if (!visibleLevels.includes(event.visibility)) {
|
||||
throw error(403, 'You do not have permission to view this event');
|
||||
}
|
||||
|
||||
// Fetch user's RSVP if they have one
|
||||
let rsvp = null;
|
||||
if (member) {
|
||||
const { data } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select('*')
|
||||
.eq('event_id', params.id)
|
||||
.eq('member_id', member.id)
|
||||
.single();
|
||||
rsvp = data;
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
rsvp
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
rsvp: async ({ request, locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'You must be logged in to RSVP' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const guestCount = parseInt(formData.get('guest_count') as string) || 0;
|
||||
|
||||
// Fetch the event to check capacity and guest limits
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
return fail(404, { error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Server-side guest count validation
|
||||
if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
|
||||
return fail(400, {
|
||||
error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed per member`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if event is full
|
||||
const totalAttending = event.total_attendees + 1 + guestCount;
|
||||
const isFull = event.max_attendees && totalAttending > event.max_attendees;
|
||||
|
||||
// Create RSVP
|
||||
const { error: rsvpError } = await locals.supabase.from('event_rsvps').insert({
|
||||
event_id: params.id,
|
||||
member_id: member.id,
|
||||
status: isFull ? 'waitlist' : 'confirmed',
|
||||
guest_count: guestCount,
|
||||
payment_status: event.is_paid ? 'pending' : 'not_required',
|
||||
payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null
|
||||
});
|
||||
|
||||
if (rsvpError) {
|
||||
if (rsvpError.code === '23505') {
|
||||
return fail(400, { error: 'You have already RSVP\'d to this event' });
|
||||
}
|
||||
console.error('RSVP error:', rsvpError);
|
||||
return fail(500, { error: 'Failed to submit RSVP. Please try again.' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: isFull
|
||||
? 'You have been added to the waitlist. We will notify you if a spot opens up.'
|
||||
: 'RSVP submitted successfully!'
|
||||
};
|
||||
},
|
||||
|
||||
updateRsvp: async ({ request, locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'You must be logged in' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const newStatus = formData.get('status') as string;
|
||||
const guestCount = parseInt(formData.get('guest_count') as string) || 0;
|
||||
|
||||
// Validate status
|
||||
const validStatuses = ['confirmed', 'declined', 'maybe'];
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
return fail(400, { error: 'Invalid RSVP status' });
|
||||
}
|
||||
|
||||
// Fetch the event for validation
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
return fail(404, { error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Get current RSVP
|
||||
const { data: currentRsvp } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select('*')
|
||||
.eq('event_id', params.id)
|
||||
.eq('member_id', member.id)
|
||||
.single();
|
||||
|
||||
if (!currentRsvp) {
|
||||
return fail(404, { error: 'RSVP not found' });
|
||||
}
|
||||
|
||||
// Validate guest count
|
||||
if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
|
||||
return fail(400, {
|
||||
error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed`
|
||||
});
|
||||
}
|
||||
|
||||
// If changing from waitlist/declined to confirmed, check capacity
|
||||
if (newStatus === 'confirmed' && currentRsvp.status !== 'confirmed') {
|
||||
const spotsNeeded = 1 + guestCount;
|
||||
const currentConfirmed = event.total_attendees;
|
||||
if (event.max_attendees && currentConfirmed + spotsNeeded > event.max_attendees) {
|
||||
return fail(400, { error: 'Event is at capacity. You will remain on the waitlist.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update RSVP
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.update({
|
||||
status: newStatus,
|
||||
guest_count: guestCount,
|
||||
payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('event_id', params.id)
|
||||
.eq('member_id', member.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Update RSVP error:', updateError);
|
||||
return fail(500, { error: 'Failed to update RSVP. Please try again.' });
|
||||
}
|
||||
|
||||
// If changing to declined from confirmed, try to promote someone from waitlist
|
||||
if (newStatus === 'declined' && currentRsvp.status === 'confirmed') {
|
||||
await promoteFromWaitlist(locals.supabase, params.id as string, event);
|
||||
}
|
||||
|
||||
const statusMessages: Record<string, string> = {
|
||||
confirmed: 'Great! You\'re now confirmed for this event.',
|
||||
declined: 'You have declined this event.',
|
||||
maybe: 'Your tentative response has been recorded.'
|
||||
};
|
||||
|
||||
return { success: statusMessages[newStatus] || 'RSVP updated successfully.' };
|
||||
},
|
||||
|
||||
cancel: async ({ locals, params }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'You must be logged in' });
|
||||
}
|
||||
|
||||
// Get current RSVP status before deleting
|
||||
const { data: currentRsvp } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.select('status')
|
||||
.eq('event_id', params.id)
|
||||
.eq('member_id', member.id)
|
||||
.single();
|
||||
|
||||
const { error: deleteError } = await locals.supabase
|
||||
.from('event_rsvps')
|
||||
.delete()
|
||||
.eq('event_id', params.id)
|
||||
.eq('member_id', member.id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Cancel RSVP error:', deleteError);
|
||||
return fail(500, { error: 'Failed to cancel RSVP. Please try again.' });
|
||||
}
|
||||
|
||||
// If cancelling a confirmed RSVP, try to promote someone from waitlist
|
||||
if (currentRsvp?.status === 'confirmed') {
|
||||
// Fetch event for capacity check
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
if (event) {
|
||||
await promoteFromWaitlist(locals.supabase, params.id as string, event);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: 'RSVP cancelled successfully.' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Promote the oldest waitlisted member to confirmed status
|
||||
*/
|
||||
async function promoteFromWaitlist(
|
||||
supabase: typeof import('@supabase/supabase-js').SupabaseClient,
|
||||
eventId: string,
|
||||
event: { max_attendees: number | null; total_attendees: number; is_paid: boolean; member_price: number; title?: string; start_datetime?: string; location?: string }
|
||||
) {
|
||||
// Check if there's room
|
||||
if (event.max_attendees && event.total_attendees >= event.max_attendees) {
|
||||
return; // Still full
|
||||
}
|
||||
|
||||
// Get oldest waitlisted member with their info
|
||||
const { data: waitlisted } = await supabase
|
||||
.from('event_rsvps')
|
||||
.select('id, member_id, guest_count, member:members(first_name, last_name, email)')
|
||||
.eq('event_id', eventId)
|
||||
.eq('status', 'waitlist')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (!waitlisted) {
|
||||
return; // No one on waitlist
|
||||
}
|
||||
|
||||
// Check if promoting them (plus guests) would exceed capacity
|
||||
const spotsNeeded = 1 + (waitlisted.guest_count || 0);
|
||||
if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) {
|
||||
return; // Not enough room for this person + guests
|
||||
}
|
||||
|
||||
// Promote to confirmed
|
||||
await supabase
|
||||
.from('event_rsvps')
|
||||
.update({
|
||||
status: 'confirmed',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', waitlisted.id);
|
||||
|
||||
// Send email notification to promoted member
|
||||
const member = waitlisted.member as { first_name: string; last_name: string; email: string } | null;
|
||||
if (member?.email) {
|
||||
const eventDate = event.start_datetime
|
||||
? new Date(event.start_datetime).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
: 'TBD';
|
||||
|
||||
await sendTemplatedEmail(
|
||||
'waitlist_promotion',
|
||||
member.email,
|
||||
{
|
||||
first_name: member.first_name,
|
||||
event_title: event.title || 'Event',
|
||||
event_date: eventDate,
|
||||
event_location: event.location || 'TBD'
|
||||
},
|
||||
{
|
||||
recipientId: waitlisted.member_id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Promoted member ${waitlisted.member_id} from waitlist for event ${eventId} and sent notification`);
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleLevels(role: string | undefined): string[] {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['public', 'members', 'board', 'admin'];
|
||||
case 'board':
|
||||
return ['public', 'members', 'board'];
|
||||
default:
|
||||
return ['public', 'members'];
|
||||
}
|
||||
}
|
||||
483
src/routes/(app)/events/[id]/+page.svelte
Normal file
483
src/routes/(app)/events/[id]/+page.svelte
Normal file
@@ -0,0 +1,483 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
Calendar,
|
||||
MapPin,
|
||||
Users,
|
||||
Clock,
|
||||
DollarSign,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
X,
|
||||
UserPlus,
|
||||
ExternalLink,
|
||||
CreditCard,
|
||||
Loader2
|
||||
} from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import AddToCalendarButton from '$lib/components/ui/AddToCalendarButton.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
let event = $derived(data.event);
|
||||
let rsvp = $derived(data.rsvp);
|
||||
let member = $derived(data.member);
|
||||
|
||||
// Check if RSVP is pending payment (for paid events)
|
||||
let isPendingPayment = $derived(
|
||||
rsvp && event?.is_paid && rsvp.payment_status === 'pending'
|
||||
);
|
||||
|
||||
let loading = $state(false);
|
||||
let guestCount = $state(rsvp?.guest_count || 0);
|
||||
|
||||
// Format date and time
|
||||
function formatDateTime(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return {
|
||||
date: date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}),
|
||||
time: date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const startDateTime = formatDateTime(event?.start_datetime || '');
|
||||
const endDateTime = formatDateTime(event?.end_datetime || '');
|
||||
|
||||
// Check if event is in the past
|
||||
const isPast = new Date(event?.end_datetime || '') < new Date();
|
||||
|
||||
// Get type color
|
||||
function getTypeColor(color: string | null): string {
|
||||
return color || '#6b7280';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{event?.title || 'Event'} | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<!-- Back button -->
|
||||
<a
|
||||
href="/events"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-monaco-600"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to events
|
||||
</a>
|
||||
|
||||
{#if event}
|
||||
<!-- Event Header -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
{#if event.cover_image_url}
|
||||
<div class="aspect-video w-full">
|
||||
<img
|
||||
src={event.cover_image_url}
|
||||
alt={event.title}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium"
|
||||
style="background-color: {getTypeColor(event.event_type_color)}20; color: {getTypeColor(
|
||||
event.event_type_color
|
||||
)}"
|
||||
>
|
||||
{event.event_type_name || 'Event'}
|
||||
</span>
|
||||
{#if event.is_paid}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-700"
|
||||
>
|
||||
<DollarSign class="h-3.5 w-3.5" />
|
||||
€{event.member_price}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-700"
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
{/if}
|
||||
{#if event.is_full}
|
||||
<span
|
||||
class="inline-flex rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-700"
|
||||
>
|
||||
Full
|
||||
</span>
|
||||
{/if}
|
||||
{#if isPast}
|
||||
<span
|
||||
class="inline-flex rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-700"
|
||||
>
|
||||
Past Event
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h1 class="mt-4 text-2xl font-bold text-slate-900 lg:text-3xl">{event.title}</h1>
|
||||
|
||||
{#if event.description}
|
||||
<p class="mt-4 whitespace-pre-wrap text-slate-600">{event.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Date & Time -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Date & Time</h2>
|
||||
{#if event && !isPast}
|
||||
<AddToCalendarButton
|
||||
eventId={event.id}
|
||||
eventTitle={event.title}
|
||||
startDatetime={event.start_datetime}
|
||||
endDatetime={event.end_datetime}
|
||||
location={event.location}
|
||||
description={event.description}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<Calendar class="h-5 w-5 flex-shrink-0 text-monaco-600" />
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{startDateTime.date}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
{startDateTime.time} - {endDateTime.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if event.location}
|
||||
<div class="flex items-start gap-3">
|
||||
<MapPin class="h-5 w-5 flex-shrink-0 text-monaco-600" />
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">{event.location}</p>
|
||||
{#if event.location_url}
|
||||
<a
|
||||
href={event.location_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1 inline-flex items-center gap-1 text-sm text-monaco-600 hover:underline"
|
||||
>
|
||||
View on map
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendees -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-slate-900">Attendees</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="h-5 w-5 text-monaco-600" />
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">
|
||||
{event.total_attendees}
|
||||
{event.max_attendees ? ` / ${event.max_attendees}` : ''} attending
|
||||
</p>
|
||||
{#if event.waitlist_count > 0}
|
||||
<p class="text-sm text-slate-500">{event.waitlist_count} on waitlist</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP Section -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-slate-900">RSVP</h2>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4">
|
||||
<FormMessage type="error" message={form.error} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-4">
|
||||
<FormMessage type="success" message={form.success} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isPast}
|
||||
<p class="text-slate-500">This event has already ended.</p>
|
||||
{:else if rsvp}
|
||||
<!-- Existing RSVP -->
|
||||
<div
|
||||
class="mb-4 rounded-lg {isPendingPayment
|
||||
? 'bg-amber-50 border border-amber-200'
|
||||
: rsvp.status === 'confirmed'
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: rsvp.status === 'waitlist'
|
||||
? 'bg-yellow-50 border border-yellow-200'
|
||||
: rsvp.status === 'maybe'
|
||||
? 'bg-blue-50 border border-blue-200'
|
||||
: 'bg-slate-50 border border-slate-200'} p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isPendingPayment}
|
||||
<Loader2 class="h-5 w-5 text-amber-600 animate-spin" />
|
||||
<span class="font-medium text-amber-700">Processing - Payment Required</span>
|
||||
{:else if rsvp.status === 'confirmed'}
|
||||
<Check class="h-5 w-5 text-green-600" />
|
||||
<span class="font-medium text-green-700">You're attending!</span>
|
||||
{:else if rsvp.status === 'waitlist'}
|
||||
<Clock class="h-5 w-5 text-yellow-600" />
|
||||
<span class="font-medium text-yellow-700">You're on the waitlist</span>
|
||||
{:else if rsvp.status === 'maybe'}
|
||||
<Clock class="h-5 w-5 text-blue-600" />
|
||||
<span class="font-medium text-blue-700">You're tentative</span>
|
||||
{:else if rsvp.status === 'declined'}
|
||||
<X class="h-5 w-5 text-slate-600" />
|
||||
<span class="font-medium text-slate-700">You declined</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isPendingPayment && rsvp.payment_amount}
|
||||
<div class="mt-3 rounded-md bg-white p-3 border border-amber-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-slate-600">Amount Due:</span>
|
||||
<span class="font-semibold text-slate-900">€{rsvp.payment_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-amber-600">
|
||||
Your RSVP will be confirmed once payment is received.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if rsvp.guest_count > 0}
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Change RSVP Status -->
|
||||
{#if rsvp.status !== 'cancelled'}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-slate-700">Change your response:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if rsvp.status !== 'confirmed' && !event.is_full}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateRsvp"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="status" value="confirmed" />
|
||||
<input type="hidden" name="guest_count" value={rsvp.guest_count} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
class="border-green-300 text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<Check class="mr-1 h-3.5 w-3.5" />
|
||||
Going
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if rsvp.status !== 'maybe'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateRsvp"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="status" value="maybe" />
|
||||
<input type="hidden" name="guest_count" value={rsvp.guest_count} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
class="border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Clock class="mr-1 h-3.5 w-3.5" />
|
||||
Maybe
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if rsvp.status !== 'declined'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateRsvp"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="status" value="declined" />
|
||||
<input type="hidden" name="guest_count" value="0" />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
class="border-slate-300 text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<X class="mr-1 h-3.5 w-3.5" />
|
||||
Can't Go
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cancel RSVP (remove completely) -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/cancel"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
{/if}
|
||||
Remove my RSVP
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- New RSVP Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rsvp"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
loading = false;
|
||||
if (result.type === 'success') {
|
||||
await invalidateAll();
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if event.max_guests_per_member > 0}
|
||||
<div class="space-y-2">
|
||||
<Label for="guest_count">Additional guests</Label>
|
||||
<Input
|
||||
id="guest_count"
|
||||
name="guest_count"
|
||||
type="number"
|
||||
min="0"
|
||||
max={event.max_guests_per_member}
|
||||
bind:value={guestCount}
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">
|
||||
Max {event.max_guests_per_member} guest{event.max_guests_per_member > 1
|
||||
? 's'
|
||||
: ''} allowed
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if event.is_paid}
|
||||
<div class="rounded-lg bg-slate-50 p-3">
|
||||
<p class="text-sm font-medium text-slate-900">
|
||||
Total: €{(event.member_price * (1 + guestCount)).toFixed(2)}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
Payment instructions will be sent after RSVP
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="monaco"
|
||||
class="w-full"
|
||||
disabled={loading || event.is_full}
|
||||
>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Submitting...
|
||||
{:else if event.is_full}
|
||||
Join Waitlist
|
||||
{:else}
|
||||
<UserPlus class="mr-2 h-4 w-4" />
|
||||
RSVP Now
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="glass-card p-12 text-center">
|
||||
<p class="text-slate-600">Event not found.</p>
|
||||
<a href="/events" class="mt-4 inline-block text-monaco-600 hover:underline">
|
||||
View all events
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
42
src/routes/(app)/payments/+page.server.ts
Normal file
42
src/routes/(app)/payments/+page.server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Fetch payment history
|
||||
const { data: payments } = await locals.supabase
|
||||
.from('dues_payments')
|
||||
.select('*')
|
||||
.eq('member_id', member?.id)
|
||||
.order('payment_date', { ascending: false });
|
||||
|
||||
// Fetch payment settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'dues');
|
||||
|
||||
// Convert settings array to object
|
||||
const paymentSettings: Record<string, string> = {};
|
||||
if (settings) {
|
||||
for (const setting of settings) {
|
||||
const key = setting.setting_key.replace('payment_', '');
|
||||
const value = setting.setting_value;
|
||||
// Handle JSON-encoded strings
|
||||
if (typeof value === 'string' && value.startsWith('"')) {
|
||||
try {
|
||||
paymentSettings[key] = JSON.parse(value);
|
||||
} catch {
|
||||
paymentSettings[key] = value;
|
||||
}
|
||||
} else {
|
||||
paymentSettings[key] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
payments: payments || [],
|
||||
paymentSettings
|
||||
};
|
||||
};
|
||||
286
src/routes/(app)/payments/+page.svelte
Normal file
286
src/routes/(app)/payments/+page.svelte
Normal file
@@ -0,0 +1,286 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
CreditCard,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Building2,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const { member, payments, paymentSettings } = data;
|
||||
|
||||
let copiedField = $state<string | null>(null);
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Get dues status info
|
||||
function getDuesInfo(status: string | null) {
|
||||
switch (status) {
|
||||
case 'current':
|
||||
return {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
label: 'Current',
|
||||
description: 'Your membership dues are paid and up to date.'
|
||||
};
|
||||
case 'due_soon':
|
||||
return {
|
||||
icon: Clock,
|
||||
color: 'text-yellow-600',
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
label: 'Due Soon',
|
||||
description: `Your dues are due on ${formatDate(member?.current_due_date)}.`
|
||||
};
|
||||
case 'overdue':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
label: 'Overdue',
|
||||
description: `Your dues are ${member?.days_overdue} days overdue. Please pay soon.`
|
||||
};
|
||||
case 'never_paid':
|
||||
default:
|
||||
return {
|
||||
icon: CreditCard,
|
||||
color: 'text-slate-600',
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-200',
|
||||
label: 'Payment Required',
|
||||
description: 'Please pay your membership dues to activate your membership.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const duesInfo = getDuesInfo(member?.dues_status);
|
||||
|
||||
// Copy to clipboard
|
||||
async function copyToClipboard(text: string, field: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedField = field;
|
||||
setTimeout(() => (copiedField = null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Payments | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Payments</h1>
|
||||
<p class="text-slate-500">View your dues status and payment history</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Dues Status -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<CreditCard class="h-5 w-5 text-monaco-600" />
|
||||
Dues Status
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-4 rounded-lg border p-4 {duesInfo.bg} {duesInfo.border}"
|
||||
>
|
||||
<svelte:component this={duesInfo.icon} class="h-6 w-6 flex-shrink-0 {duesInfo.color}" />
|
||||
<div class="flex-1">
|
||||
<p class="font-medium {duesInfo.color}">{duesInfo.label}</p>
|
||||
<p class="mt-1 text-sm text-slate-600">{duesInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-sm text-slate-500">Annual Dues</p>
|
||||
<p class="mt-1 text-xl font-semibold text-slate-900">
|
||||
€{(member?.annual_dues || 50).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-sm text-slate-500">Membership Type</p>
|
||||
<p class="mt-1 text-xl font-semibold text-slate-900">
|
||||
{member?.membership_type_name || 'Regular'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-sm text-slate-500">Last Payment</p>
|
||||
<p class="mt-1 text-xl font-semibold text-slate-900">
|
||||
{formatDate(member?.last_payment_date)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<p class="text-sm text-slate-500">Next Due Date</p>
|
||||
<p class="mt-1 text-xl font-semibold text-slate-900">
|
||||
{formatDate(member?.current_due_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<Building2 class="h-5 w-5 text-monaco-600" />
|
||||
Payment Details
|
||||
</h2>
|
||||
|
||||
<p class="mb-4 text-sm text-slate-600">
|
||||
Please make your payment via bank transfer to:
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if paymentSettings?.bank_name}
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-slate-500">Bank</p>
|
||||
<p class="text-sm text-slate-900">{paymentSettings.bank_name}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-slate-500">Account Holder</p>
|
||||
<p class="text-sm text-slate-900">
|
||||
{paymentSettings?.account_holder || 'ASSOCIATION MONACO USA'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-slate-500">IBAN</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 rounded bg-slate-100 px-2 py-1 text-xs text-slate-900">
|
||||
{paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860'}
|
||||
</code>
|
||||
<button
|
||||
onclick={() =>
|
||||
copyToClipboard(
|
||||
paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860',
|
||||
'iban'
|
||||
)}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
aria-label="Copy IBAN"
|
||||
>
|
||||
{#if copiedField === 'iban'}
|
||||
<Check class="h-4 w-4 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-slate-500">Reference</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 rounded bg-monaco-50 px-2 py-1 text-xs font-medium text-monaco-700">
|
||||
{member?.member_id}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => copyToClipboard(member?.member_id || '', 'reference')}
|
||||
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||
aria-label="Copy reference"
|
||||
>
|
||||
{#if copiedField === 'reference'}
|
||||
<Check class="h-4 w-4 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-500">
|
||||
Please include your Member ID ({member?.member_id}) in the payment reference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment History -->
|
||||
<div class="glass-card overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4">
|
||||
<h2 class="flex items-center gap-2 text-lg font-semibold text-slate-900">
|
||||
<Calendar class="h-5 w-5 text-monaco-600" />
|
||||
Payment History
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{#if payments && payments.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Amount
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Period Covered
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Reference
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||
Method
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{#each payments as payment}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-slate-900">
|
||||
{formatDate(payment.payment_date)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-slate-900">
|
||||
€{payment.amount.toFixed(2)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-slate-500">
|
||||
Until {formatDate(payment.due_date)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-slate-500">
|
||||
{payment.reference || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm capitalize text-slate-500">
|
||||
{payment.payment_method?.replace('_', ' ') || 'Bank Transfer'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<CreditCard class="mb-4 h-12 w-12 text-slate-300" />
|
||||
<p class="font-medium text-slate-900">No payments yet</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Your payment history will appear here once you make your first payment.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
167
src/routes/(app)/profile/+page.server.ts
Normal file
167
src/routes/(app)/profile/+page.server.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { uploadAvatar, deleteAvatar, isS3Enabled, getActiveAvatarUrl } from '$lib/server/storage';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Resolve the correct avatar URL based on current storage settings
|
||||
if (member) {
|
||||
const activeAvatarUrl = await getActiveAvatarUrl(member);
|
||||
return {
|
||||
member: {
|
||||
...member,
|
||||
avatar_url: activeAvatarUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { member };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const firstName = formData.get('first_name') as string;
|
||||
const lastName = formData.get('last_name') as string;
|
||||
const phone = formData.get('phone') as string;
|
||||
const address = formData.get('address') as string;
|
||||
const nationalityString = formData.get('nationality') as string;
|
||||
|
||||
// Validation
|
||||
if (!firstName || firstName.length < 2) {
|
||||
return fail(400, { error: 'First name must be at least 2 characters' });
|
||||
}
|
||||
|
||||
if (!lastName || lastName.length < 2) {
|
||||
return fail(400, { error: 'Last name must be at least 2 characters' });
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
return fail(400, { error: 'Phone number is required' });
|
||||
}
|
||||
|
||||
if (!address || address.length < 10) {
|
||||
return fail(400, { error: 'Please enter a complete address' });
|
||||
}
|
||||
|
||||
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
|
||||
if (nationality.length === 0) {
|
||||
return fail(400, { error: 'Please select at least one nationality' });
|
||||
}
|
||||
|
||||
// Update member profile (use admin client to bypass RLS)
|
||||
const { error } = await supabaseAdmin
|
||||
.from('members')
|
||||
.update({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone,
|
||||
address,
|
||||
nationality,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
return fail(500, { error: 'Failed to update profile. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Profile updated successfully!' };
|
||||
},
|
||||
|
||||
uploadAvatar: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('avatar') as File;
|
||||
|
||||
if (!file || !file.size) {
|
||||
return fail(400, { error: 'Please select an image to upload' });
|
||||
}
|
||||
|
||||
// First delete any existing avatar from both storage backends
|
||||
if (member.avatar_path) {
|
||||
await deleteAvatar(member.id, member.avatar_path);
|
||||
} else {
|
||||
await deleteAvatar(member.id);
|
||||
}
|
||||
|
||||
// Upload the avatar to appropriate storage (or both)
|
||||
const result = await uploadAvatar(member.id, file);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'Failed to upload avatar' });
|
||||
}
|
||||
|
||||
// Determine active URL based on current S3 setting
|
||||
const s3Active = await isS3Enabled();
|
||||
const activeUrl = s3Active ? result.s3Url : result.localUrl;
|
||||
|
||||
// Update member record with all avatar URLs (use admin client to bypass RLS)
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('members')
|
||||
.update({
|
||||
avatar_url: activeUrl || result.publicUrl,
|
||||
avatar_url_local: result.localUrl || null,
|
||||
avatar_url_s3: result.s3Url || null,
|
||||
avatar_path: result.path,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update avatar URL:', updateError);
|
||||
return fail(500, { error: 'Failed to update profile with new avatar' });
|
||||
}
|
||||
|
||||
return { success: 'Avatar uploaded successfully!' };
|
||||
},
|
||||
|
||||
removeAvatar: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
// Delete the avatar from BOTH storage backends using the stored path
|
||||
if (member.avatar_path) {
|
||||
await deleteAvatar(member.id, member.avatar_path);
|
||||
} else {
|
||||
// Fallback: try to delete common extensions
|
||||
await deleteAvatar(member.id);
|
||||
}
|
||||
|
||||
// Update member record to clear all avatar URLs (use admin client to bypass RLS)
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from('members')
|
||||
.update({
|
||||
avatar_url: null,
|
||||
avatar_url_local: null,
|
||||
avatar_url_s3: null,
|
||||
avatar_path: null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to remove avatar URL:', updateError);
|
||||
return fail(500, { error: 'Failed to update profile' });
|
||||
}
|
||||
|
||||
return { success: 'Avatar removed successfully!' };
|
||||
}
|
||||
};
|
||||
374
src/routes/(app)/profile/+page.svelte
Normal file
374
src/routes/(app)/profile/+page.svelte
Normal file
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { NationalitySelect, PhoneInput, CountrySelect } from '$lib/components/ui';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import { Camera, Save, Trash2, Upload } from 'lucide-svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
// Use $derived for reactivity after invalidateAll()
|
||||
const member = $derived(data.member);
|
||||
|
||||
let loading = $state(false);
|
||||
let avatarLoading = $state(false);
|
||||
let selectedNationalities = $state<string[]>(data.member?.nationality || []);
|
||||
|
||||
// Phone state
|
||||
let phoneValue = $state(data.member?.phone || '');
|
||||
let phoneCountryCode = $state('US');
|
||||
|
||||
// Address state
|
||||
let street = $state('');
|
||||
let city = $state('');
|
||||
let residenceCountry = $state('MC');
|
||||
|
||||
// Parse address when member data loads
|
||||
$effect(() => {
|
||||
if (member?.phone && !phoneValue) {
|
||||
phoneValue = member.phone;
|
||||
}
|
||||
if (member?.address) {
|
||||
// Try to parse existing address (format: "street, city, country")
|
||||
const parts = member.address.split(',').map((p: string) => p.trim());
|
||||
if (parts.length >= 3) {
|
||||
street = parts.slice(0, -2).join(', ');
|
||||
city = parts[parts.length - 2] || '';
|
||||
residenceCountry = parts[parts.length - 1] || 'MC';
|
||||
} else if (parts.length === 2) {
|
||||
street = parts[0] || '';
|
||||
city = parts[1] || '';
|
||||
} else {
|
||||
street = member.address;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Computed full address for database
|
||||
const fullAddress = $derived(`${street}, ${city}, ${residenceCountry}`);
|
||||
|
||||
function triggerAvatarUpload() {
|
||||
const input = document.getElementById('avatar-file-input') as HTMLInputElement;
|
||||
input?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>My Profile | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<!-- Profile Header -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
|
||||
<!-- Avatar -->
|
||||
<div class="relative">
|
||||
{#if member?.avatar_url}
|
||||
<img
|
||||
src={member.avatar_url}
|
||||
alt={`${member.first_name} ${member.last_name}`}
|
||||
class="h-24 w-24 rounded-full object-cover ring-4 ring-white shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-24 w-24 items-center justify-center rounded-full bg-monaco-100 text-2xl font-semibold text-monaco-700 ring-4 ring-white shadow-lg"
|
||||
>
|
||||
{member?.first_name[0]}{member?.last_name[0]}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Avatar Upload Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/uploadAvatar"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
avatarLoading = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
avatarLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="absolute bottom-0 right-0"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="avatar-file-input"
|
||||
name="avatar"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
class="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={triggerAvatarUpload}
|
||||
disabled={avatarLoading}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-monaco-600 text-white shadow-md hover:bg-monaco-700 disabled:opacity-50"
|
||||
aria-label="Change photo"
|
||||
>
|
||||
{#if avatarLoading}
|
||||
<LoadingSpinner size="sm" />
|
||||
{:else}
|
||||
<Camera class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Remove Avatar Button -->
|
||||
{#if member?.avatar_url}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeAvatar"
|
||||
use:enhance={() => {
|
||||
avatarLoading = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
avatarLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="absolute -bottom-2 -right-2"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={avatarLoading}
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:bg-red-600 disabled:opacity-50"
|
||||
aria-label="Remove photo"
|
||||
title="Remove photo"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{member?.first_name}
|
||||
{member?.last_name}
|
||||
</h2>
|
||||
<p class="text-slate-500">{member?.email}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-monaco-100 px-2.5 py-0.5 text-xs font-medium text-monaco-700"
|
||||
>
|
||||
{member?.member_id}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700"
|
||||
>
|
||||
{member?.status_display_name || 'Pending'}
|
||||
</span>
|
||||
{#if member?.role !== 'member'}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium capitalize text-purple-700"
|
||||
>
|
||||
{member?.role}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-6 text-lg font-semibold text-slate-900">Personal Information</h3>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4">
|
||||
<FormMessage type="error" message={form.error} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mb-4">
|
||||
<FormMessage type="success" message={form.success} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateProfile"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
await invalidateAll();
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<!-- Name Row -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="first_name">First name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={member?.first_name}
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="last_name">Last name</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={member?.last_name}
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email (read-only) -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={member?.email}
|
||||
disabled
|
||||
class="h-11 bg-slate-50"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">
|
||||
<a href="/settings?tab=security" class="text-monaco-600 hover:text-monaco-700 hover:underline">
|
||||
Change email in Account Settings →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div class="space-y-2">
|
||||
<Label>Phone number</Label>
|
||||
<PhoneInput
|
||||
bind:value={phoneValue}
|
||||
bind:countryCode={phoneCountryCode}
|
||||
disabled={loading}
|
||||
placeholder="Phone number"
|
||||
name="phone"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street Address -->
|
||||
<div class="space-y-2">
|
||||
<Label for="street">Street address</Label>
|
||||
<Input
|
||||
id="street"
|
||||
name="street"
|
||||
type="text"
|
||||
bind:value={street}
|
||||
placeholder="123 Avenue Princesse Grace"
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- City & Country -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="city">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
type="text"
|
||||
bind:value={city}
|
||||
placeholder="Monaco"
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Country of residence</Label>
|
||||
<CountrySelect
|
||||
bind:value={residenceCountry}
|
||||
disabled={loading}
|
||||
placeholder="Select country..."
|
||||
name="residence_country"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden full address for database -->
|
||||
<input type="hidden" name="address" value={fullAddress} />
|
||||
|
||||
<!-- Nationality - New Dropdown Select -->
|
||||
<div class="space-y-2">
|
||||
<Label>Nationality</Label>
|
||||
<NationalitySelect
|
||||
bind:value={selectedNationalities}
|
||||
disabled={loading}
|
||||
placeholder="Search and select nationalities..."
|
||||
name="nationality"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" variant="monaco" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
Save changes
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Membership Info (read-only) -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-slate-900">Membership Details</h3>
|
||||
|
||||
<dl class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm text-slate-500">Member ID</dt>
|
||||
<dd class="font-medium text-slate-900">{member?.member_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-slate-500">Membership Type</dt>
|
||||
<dd class="font-medium text-slate-900">{member?.membership_type_name || 'Regular'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-slate-500">Member Since</dt>
|
||||
<dd class="font-medium text-slate-900">
|
||||
{member?.member_since
|
||||
? new Date(member.member_since).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
: 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-slate-500">Date of Birth</dt>
|
||||
<dd class="font-medium text-slate-900">
|
||||
{member?.date_of_birth
|
||||
? new Date(member.date_of_birth).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
: 'N/A'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
422
src/routes/(app)/settings/+page.server.ts
Normal file
422
src/routes/(app)/settings/+page.server.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { uploadAvatar, deleteAvatar } from '$lib/server/storage';
|
||||
import { sendTemplatedEmail, wrapInMonacoTemplate, sendEmail } from '$lib/server/email';
|
||||
import { getMailbox, updateMailbox, type PosteConfig } from '$lib/server/poste';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const { member } = await parent();
|
||||
|
||||
// Load notification preferences
|
||||
const { data: notificationPrefs } = await locals.supabase
|
||||
.from('user_notification_preferences')
|
||||
.select('*')
|
||||
.eq('member_id', member.id)
|
||||
.single();
|
||||
|
||||
// Check if member is board/admin and has a monacousa.org email
|
||||
let monacoEmail: string | null = null;
|
||||
let hasMonacoEmailAccount = false;
|
||||
|
||||
if (member.role === 'board' || member.role === 'admin') {
|
||||
// Check if they have a monacousa.org email stored
|
||||
const { data: emailRecord } = await locals.supabase
|
||||
.from('members')
|
||||
.select('monaco_email')
|
||||
.eq('id', member.id)
|
||||
.single();
|
||||
|
||||
if (emailRecord?.monaco_email) {
|
||||
monacoEmail = emailRecord.monaco_email;
|
||||
hasMonacoEmailAccount = true;
|
||||
} else if (member.email?.endsWith('@monacousa.org')) {
|
||||
// Their primary email is a monacousa.org email
|
||||
monacoEmail = member.email;
|
||||
hasMonacoEmailAccount = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
member,
|
||||
notificationPrefs: notificationPrefs || {
|
||||
email_event_rsvp_confirmation: true,
|
||||
email_event_reminder: true,
|
||||
email_event_updates: true,
|
||||
email_waitlist_promotion: true,
|
||||
email_dues_reminder: true,
|
||||
email_payment_confirmation: true,
|
||||
email_membership_updates: true,
|
||||
email_announcements: true,
|
||||
email_newsletter: true,
|
||||
newsletter_frequency: 'monthly'
|
||||
},
|
||||
monacoEmail,
|
||||
hasMonacoEmailAccount
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateProfile: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const firstName = formData.get('first_name') as string;
|
||||
const lastName = formData.get('last_name') as string;
|
||||
const phone = formData.get('phone') as string;
|
||||
const address = formData.get('address') as string;
|
||||
const nationalityString = formData.get('nationality') as string;
|
||||
|
||||
// Validation
|
||||
if (!firstName || firstName.length < 2) {
|
||||
return fail(400, { error: 'First name must be at least 2 characters' });
|
||||
}
|
||||
|
||||
if (!lastName || lastName.length < 2) {
|
||||
return fail(400, { error: 'Last name must be at least 2 characters' });
|
||||
}
|
||||
|
||||
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
|
||||
|
||||
// Update member profile
|
||||
const { error } = await locals.supabase
|
||||
.from('members')
|
||||
.update({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
phone: phone || null,
|
||||
address: address || null,
|
||||
nationality,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
return fail(500, { error: 'Failed to update profile. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Profile updated successfully!' };
|
||||
},
|
||||
|
||||
uploadAvatar: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('avatar') as File;
|
||||
|
||||
if (!file || !file.size) {
|
||||
return fail(400, { error: 'Please select an image to upload' });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return fail(400, { error: 'Please upload a valid image (JPEG, PNG, WebP, or GIF)' });
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return fail(400, { error: 'Image must be less than 5MB' });
|
||||
}
|
||||
|
||||
// Upload the avatar - pass user's supabase client for RLS
|
||||
const result = await uploadAvatar(member.id, file, locals.supabase);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, { error: result.error || 'Failed to upload avatar' });
|
||||
}
|
||||
|
||||
// Update member record with avatar URL
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('members')
|
||||
.update({
|
||||
avatar_url: result.publicUrl,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update avatar URL:', updateError);
|
||||
return fail(500, { error: 'Failed to update profile with new avatar' });
|
||||
}
|
||||
|
||||
return { success: 'Profile picture updated successfully!' };
|
||||
},
|
||||
|
||||
removeAvatar: async ({ locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
// Delete the avatar from storage - pass user's supabase client for RLS
|
||||
await deleteAvatar(member.id, locals.supabase);
|
||||
|
||||
// Update member record to remove avatar URL
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('members')
|
||||
.update({
|
||||
avatar_url: null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', member.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to remove avatar URL:', updateError);
|
||||
return fail(500, { error: 'Failed to update profile' });
|
||||
}
|
||||
|
||||
return { success: 'Profile picture removed!' };
|
||||
},
|
||||
|
||||
updateNotifications: async ({ request, locals }) => {
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (!member) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const notificationPrefs = {
|
||||
email_event_rsvp_confirmation: formData.get('email_event_rsvp_confirmation') === 'on',
|
||||
email_event_reminder: formData.get('email_event_reminder') === 'on',
|
||||
email_event_updates: formData.get('email_event_updates') === 'on',
|
||||
email_waitlist_promotion: formData.get('email_waitlist_promotion') === 'on',
|
||||
email_dues_reminder: formData.get('email_dues_reminder') === 'on',
|
||||
email_payment_confirmation: formData.get('email_payment_confirmation') === 'on',
|
||||
email_membership_updates: formData.get('email_membership_updates') === 'on',
|
||||
email_announcements: formData.get('email_announcements') === 'on',
|
||||
email_newsletter: formData.get('email_newsletter') === 'on',
|
||||
newsletter_frequency: formData.get('newsletter_frequency') as string || 'monthly'
|
||||
};
|
||||
|
||||
// Upsert notification preferences
|
||||
const { error } = await locals.supabase
|
||||
.from('user_notification_preferences')
|
||||
.upsert({
|
||||
member_id: member.id,
|
||||
...notificationPrefs,
|
||||
updated_at: new Date().toISOString()
|
||||
}, {
|
||||
onConflict: 'member_id'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update notification preferences:', error);
|
||||
return fail(500, { error: 'Failed to update notification preferences' });
|
||||
}
|
||||
|
||||
return { success: 'Notification preferences saved!' };
|
||||
},
|
||||
|
||||
updateEmail: async ({ request, locals }) => {
|
||||
const { member, session } = await locals.safeGetSession();
|
||||
|
||||
if (!member || !session) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const newEmail = formData.get('email') as string;
|
||||
|
||||
if (!newEmail || !newEmail.includes('@')) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
if (newEmail === member.email) {
|
||||
return fail(400, { error: 'New email is the same as current email' });
|
||||
}
|
||||
|
||||
// Update email in Supabase Auth (will send verification email)
|
||||
const { error } = await locals.supabase.auth.updateUser({
|
||||
email: newEmail
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update email:', error);
|
||||
if (error.message.includes('already registered')) {
|
||||
return fail(400, { error: 'This email is already in use by another account' });
|
||||
}
|
||||
return fail(500, { error: 'Failed to update email. Please try again.' });
|
||||
}
|
||||
|
||||
return { success: 'Verification email sent to your new address. Please check your inbox.' };
|
||||
},
|
||||
|
||||
updatePassword: async ({ request, locals }) => {
|
||||
const { member, session } = await locals.safeGetSession();
|
||||
|
||||
if (!member || !session) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('current_password') as string;
|
||||
const newPassword = formData.get('new_password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
|
||||
// Validate current password is provided
|
||||
if (!currentPassword) {
|
||||
return fail(400, { error: 'Current password is required' });
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return fail(400, { error: 'New password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return fail(400, { error: 'New passwords do not match' });
|
||||
}
|
||||
|
||||
// Verify current password by re-authenticating
|
||||
const { error: authError } = await locals.supabase.auth.signInWithPassword({
|
||||
email: member.email,
|
||||
password: currentPassword
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
return fail(400, { error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Update password in Supabase Auth
|
||||
const { error } = await locals.supabase.auth.updateUser({
|
||||
password: newPassword
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update password:', error);
|
||||
return fail(500, { error: 'Failed to update password. Please try again.' });
|
||||
}
|
||||
|
||||
// Send password changed notification email
|
||||
const changedAt = new Date().toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// Try to send templated email, fall back to inline email if template doesn't exist
|
||||
const templateResult = await sendTemplatedEmail(
|
||||
'password_changed',
|
||||
member.email,
|
||||
{
|
||||
first_name: member.first_name,
|
||||
changed_at: changedAt
|
||||
},
|
||||
{
|
||||
recipientId: member.id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`
|
||||
}
|
||||
);
|
||||
|
||||
// If template doesn't exist, send a fallback email
|
||||
if (!templateResult.success && templateResult.error?.includes('not found')) {
|
||||
const fallbackContent = `
|
||||
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">Hi ${member.first_name},</p>
|
||||
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">Your Monaco USA account password was successfully changed on <strong>${changedAt}</strong>.</p>
|
||||
<div style="background: #fef3c7; border: 1px solid #fbbf24; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||
<p style="margin: 0 0 8px 0; color: #92400e; font-size: 14px; font-weight: 600;">⚠️ Didn't make this change?</p>
|
||||
<p style="margin: 0; color: #92400e; font-size: 14px;">If you did not change your password, please contact us immediately at <a href="mailto:info@monacousa.org" style="color: #92400e;">info@monacousa.org</a>.</p>
|
||||
</div>
|
||||
<p style="margin: 0; color: #64748b; font-size: 12px;">This is an automated security notification.</p>`;
|
||||
|
||||
await sendEmail({
|
||||
to: member.email,
|
||||
subject: 'Your Monaco USA Password Was Changed',
|
||||
html: wrapInMonacoTemplate({
|
||||
title: 'Password Changed',
|
||||
content: fallbackContent
|
||||
}),
|
||||
recipientId: member.id,
|
||||
recipientName: `${member.first_name} ${member.last_name}`,
|
||||
emailType: 'account'
|
||||
});
|
||||
}
|
||||
|
||||
return { success: 'Password updated successfully!' };
|
||||
},
|
||||
|
||||
updateMonacoEmailPassword: async ({ request, locals }) => {
|
||||
const { member, session } = await locals.safeGetSession();
|
||||
|
||||
if (!member || !session) {
|
||||
return fail(401, { error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
// Check if member has access to Monaco email
|
||||
if (member.role !== 'board' && member.role !== 'admin') {
|
||||
return fail(403, { error: 'Monaco email is only available for board members and admins' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const monacoEmail = formData.get('monaco_email') as string;
|
||||
const newPassword = formData.get('monaco_new_password') as string;
|
||||
const confirmPassword = formData.get('monaco_confirm_password') as string;
|
||||
|
||||
if (!monacoEmail || !monacoEmail.endsWith('@monacousa.org')) {
|
||||
return fail(400, { error: 'Invalid Monaco USA email address' });
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return fail(400, { error: 'Passwords do not match' });
|
||||
}
|
||||
|
||||
// Get Poste configuration from app_settings
|
||||
const { data: posteSettings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'poste');
|
||||
|
||||
if (!posteSettings || posteSettings.length === 0) {
|
||||
return fail(500, { error: 'Email server not configured. Please contact an administrator.' });
|
||||
}
|
||||
|
||||
const config: PosteConfig = {
|
||||
host: '',
|
||||
adminEmail: '',
|
||||
adminPassword: ''
|
||||
};
|
||||
|
||||
for (const setting of posteSettings) {
|
||||
let value = setting.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
if (setting.setting_key === 'poste_api_host') config.host = value as string;
|
||||
if (setting.setting_key === 'poste_admin_email') config.adminEmail = value as string;
|
||||
if (setting.setting_key === 'poste_admin_password') config.adminPassword = value as string;
|
||||
}
|
||||
|
||||
if (!config.host || !config.adminEmail || !config.adminPassword) {
|
||||
return fail(500, { error: 'Email server not properly configured. Please contact an administrator.' });
|
||||
}
|
||||
|
||||
// Update the mailbox password
|
||||
const result = await updateMailbox(config, monacoEmail, { password: newPassword });
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to update Monaco email password:', result.error);
|
||||
return fail(500, { error: result.error || 'Failed to update email password' });
|
||||
}
|
||||
|
||||
return { success: 'Monaco USA email password updated successfully!' };
|
||||
}
|
||||
};
|
||||
1186
src/routes/(app)/settings/+page.svelte
Normal file
1186
src/routes/(app)/settings/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
57
src/routes/(auth)/+layout.svelte
Normal file
57
src/routes/(auth)/+layout.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-12">
|
||||
<!-- Background image -->
|
||||
<div class="absolute inset-0 -z-20">
|
||||
<img
|
||||
src="/monaco_high_res.jpg"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900/80 via-slate-900/60 to-monaco-900/70"></div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative blur elements -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/30 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-white/10 blur-3xl"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo and Branding -->
|
||||
<div class="mb-8 text-center">
|
||||
<a href="/" class="inline-flex flex-col items-center">
|
||||
<div class="mb-4 overflow-hidden rounded-2xl bg-white/90 p-2 shadow-xl backdrop-blur-sm">
|
||||
<img
|
||||
src="/MONACOUSA-Flags_376x376.png"
|
||||
alt="Monaco USA"
|
||||
class="h-20 w-20 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-white drop-shadow-lg">
|
||||
Monaco <span class="text-monaco-300">USA</span>
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-white/80">Americans in Monaco</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Auth Card Container -->
|
||||
<div class="rounded-2xl bg-white p-8 shadow-xl">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="mt-6 text-center text-xs text-white/60">
|
||||
© 2026 Monaco USA. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
34
src/routes/(auth)/forgot-password/+page.server.ts
Normal file
34
src/routes/(auth)/forgot-password/+page.server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, url }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return fail(400, {
|
||||
error: 'Please enter a valid email address',
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${url.origin}/auth/reset-password`
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Don't reveal if email exists or not for security
|
||||
console.error('Password reset error:', error);
|
||||
}
|
||||
|
||||
// Always show success message (don't reveal if email exists)
|
||||
return {
|
||||
success: 'If an account exists with this email, you will receive a password reset link shortly.'
|
||||
};
|
||||
}
|
||||
};
|
||||
83
src/routes/(auth)/forgot-password/+page.svelte
Normal file
83
src/routes/(auth)/forgot-password/+page.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FormField, FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Forgot Password | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Forgot your password?</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Enter your email and we'll send you a reset link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<FormMessage type="error" message={form.error} />
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<FormMessage type="success" message={form.success} />
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-monaco-600 hover:text-monaco-700"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
label="Email address"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="monaco" size="lg" class="w-full" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Sending reset link...
|
||||
{:else}
|
||||
Send reset link
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 text-sm font-medium text-monaco-600 hover:text-monaco-700"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
90
src/routes/(auth)/login/+page.server.ts
Normal file
90
src/routes/(auth)/login/+page.server.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
// If already logged in, redirect to dashboard
|
||||
if (session) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
// Handle URL-based error messages
|
||||
const errorCode = url.searchParams.get('error');
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (errorCode === 'no_profile') {
|
||||
errorMessage = 'Your account is not properly configured. Please contact support or try signing up again.';
|
||||
} else if (errorCode === 'expired') {
|
||||
errorMessage = 'Your session has expired. Please sign in again.';
|
||||
} else if (errorCode) {
|
||||
errorMessage = decodeURIComponent(errorCode);
|
||||
}
|
||||
|
||||
return {
|
||||
redirectTo: url.searchParams.get('redirectTo') || '/dashboard',
|
||||
urlError: errorMessage
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, url }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const redirectTo = url.searchParams.get('redirectTo') || '/dashboard';
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, {
|
||||
error: 'Please enter your email and password',
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Handle specific error cases
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
return fail(400, {
|
||||
error: 'Invalid email or password. Please try again.',
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Email not confirmed')) {
|
||||
return fail(400, {
|
||||
error: 'Please verify your email address before signing in. Check your inbox for the verification link.',
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
return fail(400, {
|
||||
error: error.message,
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
// Check if member profile exists
|
||||
const { data: member } = await locals.supabase
|
||||
.from('members')
|
||||
.select('id')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (!member) {
|
||||
// User exists in auth but not in members table - unusual situation
|
||||
// They may have been deleted or there was a signup issue
|
||||
await locals.supabase.auth.signOut();
|
||||
return fail(400, {
|
||||
error: 'Your account is not properly configured. Please contact support.',
|
||||
email
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, redirectTo);
|
||||
}
|
||||
};
|
||||
110
src/routes/(auth)/login/+page.svelte
Normal file
110
src/routes/(auth)/login/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FormField, FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
|
||||
let { form, data } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign In | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Welcome back</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Sign in to your member account</p>
|
||||
</div>
|
||||
|
||||
{#if data.urlError}
|
||||
<FormMessage type="error" message={data.urlError} />
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<FormMessage type="error" message={form.error} />
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<FormMessage type="success" message={form.success} />
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
label="Email address"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
class="h-4 w-4 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500"
|
||||
/>
|
||||
<span class="text-sm text-slate-600">Remember me</span>
|
||||
</label>
|
||||
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-sm font-medium text-monaco-600 hover:text-monaco-700"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="monaco"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Signing in...
|
||||
{:else}
|
||||
Sign in
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-slate-600">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="font-medium text-monaco-600 hover:text-monaco-700">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
235
src/routes/(auth)/signup/+page.server.ts
Normal file
235
src/routes/(auth)/signup/+page.server.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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 { session } = await locals.safeGetSession();
|
||||
|
||||
// If already logged in, redirect to dashboard
|
||||
if (session) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, url }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract form fields
|
||||
const firstName = formData.get('first_name') as string;
|
||||
const lastName = formData.get('last_name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const phone = formData.get('phone') as string;
|
||||
const dateOfBirth = formData.get('date_of_birth') as string;
|
||||
const address = formData.get('address') as string;
|
||||
const nationalityString = formData.get('nationality') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
const terms = formData.get('terms');
|
||||
|
||||
// Validation
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!firstName || firstName.length < 2) {
|
||||
errors.first_name = 'First name must be at least 2 characters';
|
||||
}
|
||||
|
||||
if (!lastName || lastName.length < 2) {
|
||||
errors.last_name = 'Last name must be at least 2 characters';
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
errors.phone = 'Phone number is required';
|
||||
}
|
||||
|
||||
if (!dateOfBirth) {
|
||||
errors.date_of_birth = 'Date of birth is required';
|
||||
} else {
|
||||
// Check if 18+
|
||||
const birthDate = new Date(dateOfBirth);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
const dayDiff = today.getDate() - birthDate.getDate();
|
||||
const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age;
|
||||
|
||||
if (actualAge < 18) {
|
||||
errors.date_of_birth = 'You must be at least 18 years old to join';
|
||||
}
|
||||
}
|
||||
|
||||
if (!address || address.length < 10) {
|
||||
errors.address = 'Please enter a complete address';
|
||||
}
|
||||
|
||||
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
|
||||
if (nationality.length === 0) {
|
||||
errors.nationality = 'Please select at least one nationality';
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.confirm_password = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (!terms) {
|
||||
errors.terms = 'You must accept the terms and conditions';
|
||||
}
|
||||
|
||||
// Return validation errors
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return fail(400, {
|
||||
error: Object.values(errors)[0],
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
// Create Supabase auth user
|
||||
const { data: authData, error: authError } = await locals.supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${url.origin}/auth/callback`,
|
||||
data: {
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
if (authError.message.includes('already registered')) {
|
||||
return fail(400, {
|
||||
error: 'An account with this email already exists. Try signing in instead.',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
return fail(400, {
|
||||
error: authError.message,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
if (!authData.user) {
|
||||
return fail(500, {
|
||||
error: 'Failed to create account. Please try again.',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
// Get the default membership status (pending)
|
||||
const { data: defaultStatus, error: statusError } = await locals.supabase
|
||||
.from('membership_statuses')
|
||||
.select('id')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// Get the default membership type
|
||||
const { data: defaultType, error: typeError } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('id')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// Validate that default status and type exist
|
||||
if (statusError || !defaultStatus?.id) {
|
||||
console.error('No default membership status found:', statusError);
|
||||
// Clean up the auth user since we can't complete registration
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
return fail(500, {
|
||||
error: 'System configuration error. Please contact support.',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
if (typeError || !defaultType?.id) {
|
||||
console.error('No default membership type found:', typeError);
|
||||
// Clean up the auth user since we can't complete registration
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
return fail(500, {
|
||||
error: 'System configuration error. Please contact support.',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
// Create member profile
|
||||
const { error: memberError } = await locals.supabase.from('members').insert({
|
||||
id: authData.user.id,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address,
|
||||
nationality,
|
||||
role: 'member',
|
||||
membership_status_id: defaultStatus.id,
|
||||
membership_type_id: defaultType.id
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// Clean up the auth user since member profile creation failed
|
||||
console.error('Failed to create member profile:', memberError);
|
||||
try {
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
} catch (deleteError) {
|
||||
console.error('Failed to clean up auth user:', deleteError);
|
||||
}
|
||||
return fail(500, {
|
||||
error: 'Failed to create member profile. Please try again or contact support.',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address
|
||||
});
|
||||
}
|
||||
|
||||
// Return success - user needs to verify email
|
||||
return {
|
||||
success:
|
||||
'Account created! Please check your email to verify your account before signing in.'
|
||||
};
|
||||
}
|
||||
};
|
||||
256
src/routes/(auth)/signup/+page.svelte
Normal file
256
src/routes/(auth)/signup/+page.svelte
Normal file
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { DatePicker, NationalitySelect } from '$lib/components/ui';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let selectedNationalities = $state<string[]>([]);
|
||||
let dateOfBirth = $state<CalendarDate | undefined>(undefined);
|
||||
|
||||
// Calculate max date for 18+ years old
|
||||
const todayDate = today(getLocalTimeZone());
|
||||
const maxDateOfBirth = new CalendarDate(
|
||||
todayDate.year - 18,
|
||||
todayDate.month,
|
||||
todayDate.day
|
||||
);
|
||||
|
||||
// Parse form data on error to restore values
|
||||
$effect(() => {
|
||||
if (form?.nationality) {
|
||||
selectedNationalities = form.nationality.split(',').filter(Boolean);
|
||||
}
|
||||
if (form?.date_of_birth) {
|
||||
const [year, month, day] = form.date_of_birth.split('-').map(Number);
|
||||
dateOfBirth = new CalendarDate(year, month, day);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign Up | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Create your account</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Join the Monaco USA community</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<FormMessage type="error" message={form.error} />
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<FormMessage type="success" message={form.success} />
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Name Row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="first_name" class="text-sm font-medium text-slate-700">
|
||||
First name <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form?.first_name || ''}
|
||||
class="h-11"
|
||||
autocomplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="last_name" class="text-sm font-medium text-slate-700">
|
||||
Last name <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form?.last_name || ''}
|
||||
class="h-11"
|
||||
autocomplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email" class="text-sm font-medium text-slate-700">
|
||||
Email address <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form?.email || ''}
|
||||
class="h-11"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div class="space-y-2">
|
||||
<Label for="phone" class="text-sm font-medium text-slate-700">
|
||||
Phone number <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="+33 6 12 34 56 78"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form?.phone || ''}
|
||||
class="h-11"
|
||||
autocomplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date of Birth - New DatePicker -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Date of birth <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
bind:value={dateOfBirth}
|
||||
maxValue={maxDateOfBirth}
|
||||
disabled={loading}
|
||||
placeholder="Select your birth date"
|
||||
name="date_of_birth"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">You must be at least 18 years old to join.</p>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="space-y-2">
|
||||
<Label for="address" class="text-sm font-medium text-slate-700">
|
||||
Address <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
type="text"
|
||||
placeholder="123 Avenue Princesse Grace, Monaco"
|
||||
required
|
||||
disabled={loading}
|
||||
value={form?.address || ''}
|
||||
class="h-11"
|
||||
autocomplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nationality - New Dropdown Select -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Nationality <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<NationalitySelect
|
||||
bind:value={selectedNationalities}
|
||||
disabled={loading}
|
||||
placeholder="Search and select nationalities..."
|
||||
name="nationality"
|
||||
/>
|
||||
{#if selectedNationalities.length === 0}
|
||||
<p class="text-xs text-slate-500">Select at least one nationality.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="password" class="text-sm font-medium text-slate-700">
|
||||
Password <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={loading}
|
||||
minlength={8}
|
||||
class="h-11"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">At least 8 characters.</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm_password" class="text-sm font-medium text-slate-700">
|
||||
Confirm password <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={loading}
|
||||
minlength={8}
|
||||
class="h-11"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Terms Agreement -->
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
name="terms"
|
||||
required
|
||||
class="mt-1 h-4 w-4 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500"
|
||||
/>
|
||||
<label for="terms" class="text-sm text-slate-600">
|
||||
I agree to the
|
||||
<a href="/terms" class="text-monaco-600 hover:underline">Terms of Service</a>
|
||||
and
|
||||
<a href="/privacy" class="text-monaco-600 hover:underline">Privacy Policy</a>.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="monaco" size="lg" class="w-full" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Creating account...
|
||||
{:else}
|
||||
Create account
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-slate-600">
|
||||
Already have an account?
|
||||
<a href="/login" class="font-medium text-monaco-600 hover:text-monaco-700"> Sign in </a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
143
src/routes/+error.svelte
Normal file
143
src/routes/+error.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
// Error details with friendly messages
|
||||
const errorMessages: Record<number, { title: string; message: string; icon: string }> = {
|
||||
400: {
|
||||
title: 'Bad Request',
|
||||
message: "Something went wrong with your request. Please check and try again.",
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
|
||||
},
|
||||
401: {
|
||||
title: 'Unauthorized',
|
||||
message: 'You need to sign in to access this page.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z'
|
||||
},
|
||||
403: {
|
||||
title: 'Access Denied',
|
||||
message: "You don't have permission to view this page.",
|
||||
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'
|
||||
},
|
||||
404: {
|
||||
title: 'Page Not Found',
|
||||
message: "The page you're looking for doesn't exist or has been moved.",
|
||||
icon: 'M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
},
|
||||
500: {
|
||||
title: 'Server Error',
|
||||
message: 'Something went wrong on our end. Please try again later.',
|
||||
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
}
|
||||
};
|
||||
|
||||
const status = $derived($page.status);
|
||||
const errorInfo = $derived(
|
||||
errorMessages[status] || {
|
||||
title: 'Something Went Wrong',
|
||||
message: $page.error?.message || 'An unexpected error occurred.',
|
||||
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} - {errorInfo.title} | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-12">
|
||||
<!-- Background image -->
|
||||
<div class="absolute inset-0 -z-20">
|
||||
<img src="/monaco_high_res.jpg" alt="" class="h-full w-full object-cover" />
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-900/80 via-slate-900/60 to-monaco-900/70"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative blur elements -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/30 blur-3xl"></div>
|
||||
<div class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/20 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-white/10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-lg text-center">
|
||||
<!-- Logo -->
|
||||
<div class="mb-6 sm:mb-8 inline-flex flex-col items-center">
|
||||
<a href="/" class="inline-flex flex-col items-center">
|
||||
<div class="mb-4 overflow-hidden rounded-2xl bg-white/90 p-2 shadow-xl backdrop-blur-sm">
|
||||
<img
|
||||
src="/MONACOUSA-Flags_376x376.png"
|
||||
alt="Monaco USA"
|
||||
class="h-12 w-12 sm:h-16 sm:w-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Card -->
|
||||
<div class="rounded-2xl bg-white/95 p-4 sm:p-6 md:p-8 shadow-2xl backdrop-blur-sm">
|
||||
<!-- Error Icon -->
|
||||
<div
|
||||
class="mx-auto mb-4 sm:mb-6 flex h-16 w-16 sm:h-20 sm:w-20 items-center justify-center rounded-full bg-gradient-to-br from-red-50 to-red-100"
|
||||
>
|
||||
<svg
|
||||
class="h-8 w-8 sm:h-10 sm:w-10 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={errorInfo.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Error Code -->
|
||||
<div class="mb-2 text-4xl sm:text-6xl font-bold text-slate-900">{status}</div>
|
||||
|
||||
<!-- Error Title -->
|
||||
<h1 class="mb-3 text-2xl font-bold text-slate-900">{errorInfo.title}</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<p class="mb-8 text-slate-600">{errorInfo.message}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<Button variant="monaco" size="lg" href="/">
|
||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Go Home
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="lg" onclick={() => history.back()}>
|
||||
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<p class="mt-6 text-sm text-white/60">
|
||||
Need help?
|
||||
<a href="mailto:support@monacousa.org" class="text-monaco-300 hover:text-monaco-200">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="mt-4 text-xs text-white/40">© 2026 Monaco USA. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
22
src/routes/+layout.svelte
Normal file
22
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>Monaco USA Portal</title>
|
||||
<meta name="description" content="Monaco USA Member Portal - Americans in Monaco" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen">
|
||||
{@render children()}
|
||||
</div>
|
||||
13
src/routes/+page.server.ts
Normal file
13
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
// If logged in, go to dashboard; otherwise go to login
|
||||
if (session) {
|
||||
throw redirect(303, '/dashboard');
|
||||
} else {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
};
|
||||
222
src/routes/+page.svelte
Normal file
222
src/routes/+page.svelte
Normal file
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '$lib/components/ui/card';
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-screen overflow-hidden">
|
||||
<!-- Background decorative elements -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/10 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-monaco-300/15 blur-3xl"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="container mx-auto px-4 py-16">
|
||||
<!-- Hero Section -->
|
||||
<div class="flex flex-col items-center justify-center text-center">
|
||||
<div class="glass-card mb-8 px-6 py-3">
|
||||
<span class="text-sm font-medium text-monaco-600">Member Portal 2026</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4 text-5xl font-bold tracking-tight text-slate-900 md:text-6xl lg:text-7xl">
|
||||
Monaco <span class="text-gradient-monaco">USA</span>
|
||||
</h1>
|
||||
|
||||
<p class="mb-8 max-w-2xl text-lg text-slate-600 md:text-xl">
|
||||
Americans in Monaco - Your gateway to community events, member resources, and association
|
||||
management.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<Button variant="monaco" size="xl">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button variant="monaco-outline" size="xl">
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="mt-24 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Member Directory</CardTitle>
|
||||
<CardDescription>
|
||||
Connect with fellow Americans in Monaco through our comprehensive member directory.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Events Calendar</CardTitle>
|
||||
<CardDescription>
|
||||
Stay updated with social gatherings, meetings, and special events in the community.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Documents & Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Access meeting minutes, bylaws, and important association documents anytime.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Dues Management</CardTitle>
|
||||
<CardDescription>
|
||||
Track your membership dues, view payment history, and manage your subscription.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Receive timely reminders about events, dues, and important announcements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card class="glass-card border-0">
|
||||
<CardHeader>
|
||||
<div class="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-monaco-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Secure Access</CardTitle>
|
||||
<CardDescription>
|
||||
Role-based access ensures members, board, and admins see exactly what they need.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="mt-24 glass-card p-8">
|
||||
<div class="grid gap-8 md:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-monaco-600">150+</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Active Members</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-monaco-600">50+</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Events Per Year</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-monaco-600">25+</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Years Active</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-4xl font-bold text-monaco-600">100%</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Community Driven</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-24 text-center text-sm text-slate-500">
|
||||
<p>© 2026 Monaco USA. All rights reserved.</p>
|
||||
<p class="mt-2">Americans in Monaco since 1999</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
16
src/routes/api/auth/check-verification/+server.ts
Normal file
16
src/routes/api/auth/check-verification/+server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session || !user) {
|
||||
return json({ verified: false, error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
// In Supabase, this is stored in user metadata
|
||||
const emailVerified = user.email_confirmed_at !== null;
|
||||
|
||||
return json({ verified: emailVerified });
|
||||
};
|
||||
30
src/routes/api/auth/resend-verification/+server.ts
Normal file
30
src/routes/api/auth/resend-verification/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, url }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session || !user) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return json({ error: 'No email associated with account' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resend verification email
|
||||
const { error } = await locals.supabase.auth.resend({
|
||||
type: 'signup',
|
||||
email: user.email,
|
||||
options: {
|
||||
emailRedirectTo: `${url.origin}/join?verified=true`
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to resend verification email:', error);
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success: true, message: 'Verification email sent' });
|
||||
};
|
||||
97
src/routes/api/calendar/events/[id]/+server.ts
Normal file
97
src/routes/api/calendar/events/[id]/+server.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* API endpoint for downloading a single event as an .ics file
|
||||
* Requires authentication for non-public events
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateSingleEventIcal } from '$lib/server/ical';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, url }) => {
|
||||
const eventId = params.id;
|
||||
|
||||
// Get user session
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
// Fetch the event
|
||||
const { data: event, error: fetchError } = await locals.supabase
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
location,
|
||||
location_url,
|
||||
timezone,
|
||||
status,
|
||||
visibility,
|
||||
all_day,
|
||||
event_type:event_types(name)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !event) {
|
||||
throw error(404, 'Event not found');
|
||||
}
|
||||
|
||||
// Check visibility permissions
|
||||
const canView = checkVisibility(event.visibility, member?.role);
|
||||
if (!canView) {
|
||||
throw error(403, 'You do not have permission to view this event');
|
||||
}
|
||||
|
||||
// Generate iCal content
|
||||
const baseUrl = url.origin || 'https://monacousa.org';
|
||||
const icalContent = generateSingleEventIcal(
|
||||
{
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || undefined,
|
||||
start_datetime: event.start_datetime,
|
||||
end_datetime: event.end_datetime,
|
||||
location: event.location,
|
||||
location_url: event.location_url,
|
||||
timezone: event.timezone || 'Europe/Monaco',
|
||||
status: event.status as 'published' | 'cancelled' | 'draft',
|
||||
event_type_name: (event.event_type as { name: string } | null)?.name,
|
||||
organizer_name: 'Monaco USA',
|
||||
organizer_email: 'events@monacousa.org',
|
||||
all_day: event.all_day || false
|
||||
},
|
||||
baseUrl
|
||||
);
|
||||
|
||||
// Generate filename
|
||||
const sanitizedTitle = event.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const filename = `monaco-usa-${sanitizedTitle}.ics`;
|
||||
|
||||
return new Response(icalContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function checkVisibility(visibility: string, role?: string): boolean {
|
||||
switch (visibility) {
|
||||
case 'public':
|
||||
return true;
|
||||
case 'members':
|
||||
return !!role; // Any authenticated member
|
||||
case 'board':
|
||||
return role === 'board' || role === 'admin';
|
||||
case 'admin':
|
||||
return role === 'admin';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
117
src/routes/api/calendar/feed/+server.ts
Normal file
117
src/routes/api/calendar/feed/+server.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API endpoint for subscribing to Monaco USA events calendar feed
|
||||
* Returns iCal feed of upcoming events
|
||||
*
|
||||
* Usage:
|
||||
* - /api/calendar/feed - Public events only (no auth required)
|
||||
* - /api/calendar/feed?token=xxx - Member events with auth token
|
||||
*
|
||||
* Subscribe URL: webcal://yourdomain.com/api/calendar/feed
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateCalendarFeed, type ICalEvent } from '$lib/server/ical';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const token = url.searchParams.get('token');
|
||||
const includePrivate = url.searchParams.get('private') === 'true';
|
||||
|
||||
// Determine visibility level based on authentication
|
||||
let visibilityLevels = ['public'];
|
||||
let calendarName = 'Monaco USA Public Events';
|
||||
|
||||
// Check if user is authenticated (via session or token)
|
||||
const { member } = await locals.safeGetSession();
|
||||
|
||||
if (member) {
|
||||
// Authenticated user - include member events
|
||||
visibilityLevels = getVisibilityLevels(member.role);
|
||||
calendarName = 'Monaco USA Events';
|
||||
} else if (token) {
|
||||
// Token-based access (for calendar subscriptions)
|
||||
// Verify the token against a member's calendar token
|
||||
const { data: memberWithToken } = await supabaseAdmin
|
||||
.from('members')
|
||||
.select('id, role')
|
||||
.eq('calendar_token', token)
|
||||
.single();
|
||||
|
||||
if (memberWithToken) {
|
||||
visibilityLevels = getVisibilityLevels(memberWithToken.role);
|
||||
calendarName = 'Monaco USA Events';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch upcoming events
|
||||
const now = new Date();
|
||||
const threeMonthsFromNow = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const { data: events, error: fetchError } = await supabaseAdmin
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
location,
|
||||
location_url,
|
||||
timezone,
|
||||
status,
|
||||
visibility,
|
||||
all_day,
|
||||
event_type:event_types(name)
|
||||
`)
|
||||
.in('visibility', visibilityLevels)
|
||||
.eq('status', 'published')
|
||||
.gte('start_datetime', now.toISOString())
|
||||
.lte('start_datetime', threeMonthsFromNow.toISOString())
|
||||
.order('start_datetime', { ascending: true });
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Error fetching events for feed:', fetchError);
|
||||
return new Response('Error fetching events', { status: 500 });
|
||||
}
|
||||
|
||||
// Convert to ICalEvent format
|
||||
const baseUrl = url.origin || 'https://monacousa.org';
|
||||
const icalEvents: ICalEvent[] = (events || []).map(event => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || undefined,
|
||||
start_datetime: event.start_datetime,
|
||||
end_datetime: event.end_datetime,
|
||||
location: event.location,
|
||||
location_url: event.location_url,
|
||||
timezone: event.timezone || 'Europe/Monaco',
|
||||
status: event.status as 'published' | 'cancelled' | 'draft',
|
||||
event_type_name: (event.event_type as { name: string } | null)?.name,
|
||||
organizer_name: 'Monaco USA',
|
||||
organizer_email: 'events@monacousa.org',
|
||||
all_day: event.all_day || false
|
||||
}));
|
||||
|
||||
// Generate iCal feed
|
||||
const icalContent = generateCalendarFeed(icalEvents, calendarName, baseUrl);
|
||||
|
||||
return new Response(icalContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||
'X-WR-CALNAME': calendarName
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function getVisibilityLevels(role?: string): string[] {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['public', 'members', 'board', 'admin'];
|
||||
case 'board':
|
||||
return ['public', 'members', 'board'];
|
||||
case 'member':
|
||||
default:
|
||||
return ['public', 'members'];
|
||||
}
|
||||
}
|
||||
77
src/routes/api/calendar/public/events/[id]/+server.ts
Normal file
77
src/routes/api/calendar/public/events/[id]/+server.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* API endpoint for downloading a public event as an .ics file
|
||||
* No authentication required - only works for public visibility events
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { generateSingleEventIcal } from '$lib/server/ical';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const eventId = params.id;
|
||||
|
||||
// Fetch the event using supabaseAdmin since this is a public endpoint
|
||||
const { supabaseAdmin } = await import('$lib/server/supabase');
|
||||
|
||||
const { data: event, error: fetchError } = await supabaseAdmin
|
||||
.from('events')
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
location,
|
||||
location_url,
|
||||
timezone,
|
||||
status,
|
||||
visibility,
|
||||
all_day,
|
||||
event_type:event_types(name)
|
||||
`)
|
||||
.eq('id', eventId)
|
||||
.eq('visibility', 'public')
|
||||
.eq('status', 'published')
|
||||
.single();
|
||||
|
||||
if (fetchError || !event) {
|
||||
throw error(404, 'Event not found or not publicly accessible');
|
||||
}
|
||||
|
||||
// Generate iCal content
|
||||
const baseUrl = url.origin || 'https://monacousa.org';
|
||||
const icalContent = generateSingleEventIcal(
|
||||
{
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description: event.description || undefined,
|
||||
start_datetime: event.start_datetime,
|
||||
end_datetime: event.end_datetime,
|
||||
location: event.location,
|
||||
location_url: event.location_url,
|
||||
timezone: event.timezone || 'Europe/Monaco',
|
||||
status: event.status as 'published' | 'cancelled' | 'draft',
|
||||
event_type_name: (event.event_type as { name: string } | null)?.name,
|
||||
organizer_name: 'Monaco USA',
|
||||
organizer_email: 'events@monacousa.org',
|
||||
all_day: event.all_day || false
|
||||
},
|
||||
baseUrl
|
||||
);
|
||||
|
||||
// Generate filename
|
||||
const sanitizedTitle = event.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
const filename = `monaco-usa-${sanitizedTitle}.ics`;
|
||||
|
||||
return new Response(icalContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'public, max-age=300' // Cache for 5 minutes
|
||||
}
|
||||
});
|
||||
};
|
||||
256
src/routes/api/cron/dues-reminders/+server.ts
Normal file
256
src/routes/api/cron/dues-reminders/+server.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Cron API Endpoint for Automated Dues Reminders
|
||||
*
|
||||
* This endpoint should be called daily by an external cron service
|
||||
* (e.g., Vercel Cron, GitHub Actions, or a server cron job)
|
||||
*
|
||||
* Security: Requires CRON_SECRET header for authentication
|
||||
*
|
||||
* Example cron setup (daily at 9 AM):
|
||||
* 0 9 * * * curl -X POST https://yourdomain.com/api/cron/dues-reminders \
|
||||
* -H "Authorization: Bearer YOUR_CRON_SECRET"
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import {
|
||||
getDuesSettings,
|
||||
sendBulkReminders,
|
||||
processGracePeriodExpirations,
|
||||
getMembersNeedingReminder,
|
||||
getMembersNeedingOnboardingReminder,
|
||||
sendOnboardingReminders,
|
||||
processOnboardingExpirations,
|
||||
type ReminderType,
|
||||
type OnboardingReminderType
|
||||
} from '$lib/server/dues';
|
||||
|
||||
const CRON_SECRET = env.CRON_SECRET;
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!CRON_SECRET) {
|
||||
console.error('CRON_SECRET not configured');
|
||||
return json({ error: 'Server not configured for cron jobs' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (token !== CRON_SECRET) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org';
|
||||
const dryRun = url.searchParams.get('dry_run') === 'true';
|
||||
|
||||
try {
|
||||
// Load reminder settings
|
||||
const settings = await getDuesSettings();
|
||||
const reminderDays = settings.reminder_days_before || [30, 7, 1];
|
||||
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
dryRun,
|
||||
settings: {
|
||||
reminder_days_before: reminderDays,
|
||||
grace_period_days: settings.grace_period_days,
|
||||
auto_inactive_enabled: settings.auto_inactive_enabled
|
||||
},
|
||||
reminders: {
|
||||
due_soon_30: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
due_soon_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
due_soon_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }
|
||||
} as Record<string, { eligible: number; sent: number; skipped: number; errors: string[] }>,
|
||||
overdue: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
graceWarning: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
// Onboarding reminders for new members with payment_deadline
|
||||
onboarding: {
|
||||
reminder_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
reminder_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
|
||||
expired: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }
|
||||
},
|
||||
inactivated: [] as Array<{ id: string; name: string; email: string }>,
|
||||
onboardingInactivated: [] as Array<{ id: string; name: string; email: string }>,
|
||||
summary: {
|
||||
totalRemindersSent: 0,
|
||||
totalErrors: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Process each reminder tier
|
||||
for (const days of reminderDays) {
|
||||
const reminderType = `due_soon_${days}` as ReminderType;
|
||||
|
||||
// Get eligible members
|
||||
const eligibleMembers = await getMembersNeedingReminder(reminderType);
|
||||
results.reminders[reminderType] = {
|
||||
eligible: eligibleMembers.length,
|
||||
sent: 0,
|
||||
skipped: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
if (!dryRun && eligibleMembers.length > 0) {
|
||||
const result = await sendBulkReminders(reminderType, baseUrl);
|
||||
results.reminders[reminderType].sent = result.sent;
|
||||
results.reminders[reminderType].skipped = result.skipped;
|
||||
results.reminders[reminderType].errors = result.errors;
|
||||
results.summary.totalRemindersSent += result.sent;
|
||||
results.summary.totalErrors += result.errors.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Process overdue notifications
|
||||
const overdueMembers = await getMembersNeedingReminder('overdue');
|
||||
results.overdue.eligible = overdueMembers.length;
|
||||
|
||||
if (!dryRun && overdueMembers.length > 0) {
|
||||
const overdueResult = await sendBulkReminders('overdue', baseUrl);
|
||||
results.overdue.sent = overdueResult.sent;
|
||||
results.overdue.skipped = overdueResult.skipped;
|
||||
results.overdue.errors = overdueResult.errors;
|
||||
results.summary.totalRemindersSent += overdueResult.sent;
|
||||
results.summary.totalErrors += overdueResult.errors.length;
|
||||
}
|
||||
|
||||
// Process grace period warnings
|
||||
const graceMembers = await getMembersNeedingReminder('grace_period');
|
||||
results.graceWarning.eligible = graceMembers.length;
|
||||
|
||||
if (!dryRun && graceMembers.length > 0) {
|
||||
const graceResult = await sendBulkReminders('grace_period', baseUrl);
|
||||
results.graceWarning.sent = graceResult.sent;
|
||||
results.graceWarning.skipped = graceResult.skipped;
|
||||
results.graceWarning.errors = graceResult.errors;
|
||||
results.summary.totalRemindersSent += graceResult.sent;
|
||||
results.summary.totalErrors += graceResult.errors.length;
|
||||
}
|
||||
|
||||
// Process grace period expirations (mark members as inactive)
|
||||
if (!dryRun && settings.auto_inactive_enabled) {
|
||||
const inactivationResult = await processGracePeriodExpirations(baseUrl);
|
||||
results.inactivated = inactivationResult.members;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ONBOARDING REMINDERS (new members with payment_deadline)
|
||||
// ============================================
|
||||
|
||||
// Process 7-day onboarding reminders
|
||||
const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7');
|
||||
results.onboarding.reminder_7.eligible = onboarding7Members.length;
|
||||
|
||||
if (!dryRun && onboarding7Members.length > 0) {
|
||||
const onboarding7Result = await sendOnboardingReminders('onboarding_reminder_7', baseUrl);
|
||||
results.onboarding.reminder_7.sent = onboarding7Result.sent;
|
||||
results.onboarding.reminder_7.skipped = onboarding7Result.skipped;
|
||||
results.onboarding.reminder_7.errors = onboarding7Result.errors;
|
||||
results.summary.totalRemindersSent += onboarding7Result.sent;
|
||||
results.summary.totalErrors += onboarding7Result.errors.length;
|
||||
}
|
||||
|
||||
// Process 1-day onboarding reminders (final warning)
|
||||
const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1');
|
||||
results.onboarding.reminder_1.eligible = onboarding1Members.length;
|
||||
|
||||
if (!dryRun && onboarding1Members.length > 0) {
|
||||
const onboarding1Result = await sendOnboardingReminders('onboarding_reminder_1', baseUrl);
|
||||
results.onboarding.reminder_1.sent = onboarding1Result.sent;
|
||||
results.onboarding.reminder_1.skipped = onboarding1Result.skipped;
|
||||
results.onboarding.reminder_1.errors = onboarding1Result.errors;
|
||||
results.summary.totalRemindersSent += onboarding1Result.sent;
|
||||
results.summary.totalErrors += onboarding1Result.errors.length;
|
||||
}
|
||||
|
||||
// Process expired onboarding deadlines (mark as inactive)
|
||||
if (!dryRun && settings.auto_inactive_enabled) {
|
||||
const onboardingExpiredResult = await processOnboardingExpirations(baseUrl);
|
||||
results.onboardingInactivated = onboardingExpiredResult.members;
|
||||
}
|
||||
|
||||
return json(results);
|
||||
} catch (error) {
|
||||
console.error('Cron job error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json(
|
||||
{
|
||||
error: 'Internal server error',
|
||||
message: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET endpoint for checking cron status and getting preview of pending actions
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!CRON_SECRET || token !== CRON_SECRET) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getDuesSettings();
|
||||
const reminderDays = settings.reminder_days_before || [30, 7, 1];
|
||||
|
||||
const preview = {
|
||||
timestamp: new Date().toISOString(),
|
||||
settings: {
|
||||
reminder_days_before: reminderDays,
|
||||
grace_period_days: settings.grace_period_days,
|
||||
auto_inactive_enabled: settings.auto_inactive_enabled
|
||||
},
|
||||
pendingActions: {
|
||||
due_soon_30: 0,
|
||||
due_soon_7: 0,
|
||||
due_soon_1: 0,
|
||||
overdue: 0,
|
||||
grace_period: 0
|
||||
} as Record<string, number>,
|
||||
pendingOnboarding: {
|
||||
onboarding_reminder_7: 0,
|
||||
onboarding_reminder_1: 0,
|
||||
onboarding_expired: 0
|
||||
} as Record<string, number>
|
||||
};
|
||||
|
||||
// Count pending for each type
|
||||
for (const days of reminderDays) {
|
||||
const reminderType = `due_soon_${days}` as ReminderType;
|
||||
const members = await getMembersNeedingReminder(reminderType);
|
||||
preview.pendingActions[reminderType] = members.length;
|
||||
}
|
||||
|
||||
// Count overdue
|
||||
const overdueMembers = await getMembersNeedingReminder('overdue');
|
||||
preview.pendingActions.overdue = overdueMembers.length;
|
||||
|
||||
// Count grace period warnings
|
||||
const graceMembers = await getMembersNeedingReminder('grace_period');
|
||||
preview.pendingActions.grace_period = graceMembers.length;
|
||||
|
||||
// Count onboarding reminders
|
||||
const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7');
|
||||
preview.pendingOnboarding.onboarding_reminder_7 = onboarding7Members.length;
|
||||
|
||||
const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1');
|
||||
preview.pendingOnboarding.onboarding_reminder_1 = onboarding1Members.length;
|
||||
|
||||
const onboardingExpiredMembers = await getMembersNeedingOnboardingReminder('onboarding_expired');
|
||||
preview.pendingOnboarding.onboarding_expired = onboardingExpiredMembers.length;
|
||||
|
||||
return json(preview);
|
||||
} catch (error) {
|
||||
console.error('Cron preview error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: 'Internal server error', message: errorMessage }, { status: 500 });
|
||||
}
|
||||
};
|
||||
158
src/routes/api/cron/event-reminders/+server.ts
Normal file
158
src/routes/api/cron/event-reminders/+server.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Cron API Endpoint for Automated Event Reminders
|
||||
*
|
||||
* This endpoint should be called hourly by an external cron service
|
||||
* to send reminder emails to members 24 hours before events.
|
||||
*
|
||||
* Security: Requires CRON_SECRET header for authentication
|
||||
*
|
||||
* Example cron setup (hourly):
|
||||
* 0 * * * * curl -X POST https://yourdomain.com/api/cron/event-reminders \
|
||||
* -H "Authorization: Bearer YOUR_CRON_SECRET"
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import {
|
||||
getEventReminderSettings,
|
||||
getEventsNeedingReminders,
|
||||
sendEventReminders,
|
||||
getEventReminderStats
|
||||
} from '$lib/server/event-reminders';
|
||||
|
||||
const CRON_SECRET = env.CRON_SECRET;
|
||||
|
||||
export const POST: RequestHandler = async ({ request, url }) => {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!CRON_SECRET) {
|
||||
console.error('CRON_SECRET not configured');
|
||||
return json({ error: 'Server not configured for cron jobs' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (token !== CRON_SECRET) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org';
|
||||
const dryRun = url.searchParams.get('dry_run') === 'true';
|
||||
|
||||
try {
|
||||
// Load settings
|
||||
const settings = await getEventReminderSettings();
|
||||
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
dryRun,
|
||||
settings: {
|
||||
event_reminders_enabled: settings.event_reminders_enabled,
|
||||
event_reminder_hours_before: settings.event_reminder_hours_before
|
||||
},
|
||||
eligible: 0,
|
||||
sent: 0,
|
||||
skipped: 0,
|
||||
errors: [] as string[],
|
||||
reminders: [] as Array<{
|
||||
eventId: string;
|
||||
eventTitle: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
email: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>
|
||||
};
|
||||
|
||||
if (!settings.event_reminders_enabled) {
|
||||
return json({
|
||||
...results,
|
||||
message: 'Event reminders are disabled'
|
||||
});
|
||||
}
|
||||
|
||||
// Get events needing reminders
|
||||
const eventsNeeding = await getEventsNeedingReminders();
|
||||
results.eligible = eventsNeeding.length;
|
||||
|
||||
if (dryRun) {
|
||||
// Just show what would be sent
|
||||
results.reminders = eventsNeeding.map(e => ({
|
||||
eventId: e.event_id,
|
||||
eventTitle: e.event_title,
|
||||
memberId: e.member_id,
|
||||
memberName: `${e.first_name} ${e.last_name}`,
|
||||
email: e.email,
|
||||
status: 'would_send'
|
||||
}));
|
||||
return json(results);
|
||||
}
|
||||
|
||||
// Send the reminders
|
||||
if (eventsNeeding.length > 0) {
|
||||
const sendResult = await sendEventReminders(baseUrl);
|
||||
results.sent = sendResult.sent;
|
||||
results.skipped = sendResult.skipped;
|
||||
results.errors = sendResult.errors;
|
||||
results.reminders = sendResult.reminders;
|
||||
}
|
||||
|
||||
return json(results);
|
||||
} catch (error) {
|
||||
console.error('Event reminders cron job error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json(
|
||||
{
|
||||
error: 'Internal server error',
|
||||
message: errorMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET endpoint for checking status and getting preview of pending reminders
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (!CRON_SECRET || token !== CRON_SECRET) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getEventReminderSettings();
|
||||
const eventsNeeding = await getEventsNeedingReminders();
|
||||
const stats = await getEventReminderStats();
|
||||
|
||||
const preview = {
|
||||
timestamp: new Date().toISOString(),
|
||||
settings: {
|
||||
event_reminders_enabled: settings.event_reminders_enabled,
|
||||
event_reminder_hours_before: settings.event_reminder_hours_before
|
||||
},
|
||||
pendingReminders: eventsNeeding.length,
|
||||
pendingDetails: eventsNeeding.map(e => ({
|
||||
eventId: e.event_id,
|
||||
eventTitle: e.event_title,
|
||||
eventStart: e.start_datetime,
|
||||
memberName: `${e.first_name} ${e.last_name}`,
|
||||
email: e.email,
|
||||
guestCount: e.guest_count
|
||||
})),
|
||||
stats
|
||||
};
|
||||
|
||||
return json(preview);
|
||||
} catch (error) {
|
||||
console.error('Event reminders preview error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: 'Internal server error', message: errorMessage }, { status: 500 });
|
||||
}
|
||||
};
|
||||
32
src/routes/auth/callback/+server.ts
Normal file
32
src/routes/auth/callback/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Auth callback handler for email verification and OAuth redirects
|
||||
* This endpoint exchanges the auth code for a session
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const code = url.searchParams.get('code');
|
||||
const next = url.searchParams.get('next') || '/dashboard';
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Handle error from Supabase auth
|
||||
if (error) {
|
||||
console.error('Auth callback error:', error, errorDescription);
|
||||
throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || error)}`);
|
||||
}
|
||||
|
||||
// Exchange the code for a session
|
||||
if (code) {
|
||||
const { error: exchangeError } = await locals.supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
if (exchangeError) {
|
||||
console.error('Failed to exchange code for session:', exchangeError);
|
||||
throw redirect(303, `/login?error=${encodeURIComponent('Failed to verify email. Please try again.')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the next page or dashboard
|
||||
throw redirect(303, next);
|
||||
};
|
||||
111
src/routes/auth/reset-password/+page.server.ts
Normal file
111
src/routes/auth/reset-password/+page.server.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
// Check for token in URL (from email link via /auth/verify redirect)
|
||||
const token = url.searchParams.get('token');
|
||||
const type = url.searchParams.get('type');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
// If there's an error from a previous attempt, show it
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
// If there's a token, we need to verify it to establish a session
|
||||
if (token) {
|
||||
try {
|
||||
// For recovery/invite tokens, verify the OTP
|
||||
const otpType = type === 'invite' ? 'invite' : 'recovery';
|
||||
|
||||
const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({
|
||||
token_hash: token,
|
||||
type: otpType
|
||||
});
|
||||
|
||||
if (verifyError) {
|
||||
console.error('Token verification error:', verifyError);
|
||||
// Token invalid or expired
|
||||
throw redirect(
|
||||
303,
|
||||
`/forgot-password?error=${encodeURIComponent(verifyError.message || 'Invalid or expired reset link. Please request a new one.')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (data.session) {
|
||||
// Session established - user can now reset password
|
||||
return {
|
||||
isInvite: type === 'invite',
|
||||
email: data.user?.email
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Check if it's a redirect (which is expected)
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
throw e;
|
||||
}
|
||||
console.error('Verification error:', e);
|
||||
throw redirect(303, '/forgot-password?error=expired');
|
||||
}
|
||||
}
|
||||
|
||||
// No token - check if user has an existing session (from successful verification)
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
if (!session) {
|
||||
// No session and no token - invalid access
|
||||
throw redirect(303, '/forgot-password?error=expired');
|
||||
}
|
||||
|
||||
return {
|
||||
email: session.user?.email
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const formData = await request.formData();
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
|
||||
// Validation
|
||||
if (!password || password.length < 8) {
|
||||
return fail(400, {
|
||||
error: 'Password must be at least 8 characters long'
|
||||
});
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, {
|
||||
error: 'Passwords do not match'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has a session
|
||||
const { session } = await locals.safeGetSession();
|
||||
if (!session) {
|
||||
return fail(401, {
|
||||
error: 'Session expired. Please request a new password reset link.'
|
||||
});
|
||||
}
|
||||
|
||||
// Update the password
|
||||
const { error } = await locals.supabase.auth.updateUser({
|
||||
password
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Password update error:', error);
|
||||
return fail(400, {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Sign out after password change so they can sign in fresh
|
||||
await locals.supabase.auth.signOut();
|
||||
|
||||
return {
|
||||
success: 'Your password has been set successfully! You can now sign in with your new password.'
|
||||
};
|
||||
}
|
||||
};
|
||||
125
src/routes/auth/reset-password/+page.svelte
Normal file
125
src/routes/auth/reset-password/+page.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FormField, FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
const isInvite = data?.isInvite;
|
||||
const email = data?.email;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{isInvite ? 'Set Your Password' : 'Reset Password'} | Monaco USA</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-12">
|
||||
<!-- Background decorative elements -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/10 blur-3xl"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo and Branding -->
|
||||
<div class="mb-8 text-center">
|
||||
<a href="/" class="inline-flex flex-col items-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-monaco-600 shadow-lg">
|
||||
<span class="text-2xl font-bold text-white">M</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">
|
||||
Monaco <span class="text-monaco-600">USA</span>
|
||||
</h1>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-8">
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{isInvite ? 'Welcome to Monaco USA!' : 'Reset your password'}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{#if isInvite}
|
||||
Set a password to activate your account
|
||||
{:else}
|
||||
Enter a new password for your account
|
||||
{/if}
|
||||
</p>
|
||||
{#if email}
|
||||
<p class="mt-2 text-sm text-slate-600">{email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<FormMessage type="error" message={form.error} />
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<FormMessage type="success" message={form.success} />
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700"
|
||||
>
|
||||
Sign in with new password
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
label="New password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirm new password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={confirmPassword}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-slate-500">Password must be at least 8 characters long.</p>
|
||||
|
||||
<Button type="submit" variant="monaco" size="lg" class="w-full" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Resetting password...
|
||||
{:else}
|
||||
Reset password
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
66
src/routes/auth/verify/+server.ts
Normal file
66
src/routes/auth/verify/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Auth verify handler for email links from Supabase/GoTrue
|
||||
* This handles invite, recovery, confirmation, and email change tokens
|
||||
*
|
||||
* Flow:
|
||||
* 1. User clicks link in email (e.g., password reset)
|
||||
* 2. Link goes to /auth/verify?token=...&type=recovery&redirect_to=...
|
||||
* 3. This handler extracts parameters and redirects to the appropriate SvelteKit page
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const token = url.searchParams.get('token');
|
||||
const type = url.searchParams.get('type');
|
||||
const redirectTo = url.searchParams.get('redirect_to');
|
||||
|
||||
console.log('Auth verify handler:', { token: token?.substring(0, 20) + '...', type, redirectTo });
|
||||
|
||||
// Handle different verification types
|
||||
if (type === 'recovery' || type === 'rec') {
|
||||
// Password reset - redirect to reset password page with token
|
||||
const resetUrl = new URL('/auth/reset-password', url.origin);
|
||||
if (token) resetUrl.searchParams.set('token', token);
|
||||
if (type) resetUrl.searchParams.set('type', type);
|
||||
throw redirect(303, resetUrl.toString());
|
||||
}
|
||||
|
||||
if (type === 'invite' || type === 'inv') {
|
||||
// Member invitation - redirect to set password page
|
||||
const resetUrl = new URL('/auth/reset-password', url.origin);
|
||||
if (token) resetUrl.searchParams.set('token', token);
|
||||
resetUrl.searchParams.set('type', 'invite');
|
||||
throw redirect(303, resetUrl.toString());
|
||||
}
|
||||
|
||||
if (type === 'signup' || type === 'confirmation' || type === 'email_change') {
|
||||
// Email confirmation - try to verify directly then redirect
|
||||
if (token) {
|
||||
try {
|
||||
const { error } = await locals.supabase.auth.verifyOtp({
|
||||
token_hash: token,
|
||||
type: type === 'email_change' ? 'email_change' : 'signup'
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Email verification error:', error);
|
||||
throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
|
||||
// Success - redirect to dashboard
|
||||
throw redirect(303, redirectTo || '/dashboard');
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'status' in e) {
|
||||
// This is a redirect, rethrow it
|
||||
throw e;
|
||||
}
|
||||
console.error('Verification error:', e);
|
||||
throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: redirect to login with error
|
||||
throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`);
|
||||
};
|
||||
57
src/routes/join/+layout.svelte
Normal file
57
src/routes/join/+layout.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-4 md:py-6 lg:py-8">
|
||||
<!-- Background image -->
|
||||
<div class="absolute inset-0 -z-20">
|
||||
<img
|
||||
src="/monaco_high_res.jpg"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900/80 via-slate-900/60 to-monaco-900/70"></div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative blur elements -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div
|
||||
class="absolute -left-40 -top-40 h-80 w-80 rounded-full bg-monaco-500/30 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -right-40 top-1/3 h-96 w-96 rounded-full bg-monaco-400/20 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-white/10 blur-3xl"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-2xl">
|
||||
<!-- Logo and Branding - Compact on desktop -->
|
||||
<div class="mb-3 md:mb-4 text-center">
|
||||
<a href="/" class="inline-flex items-center gap-3">
|
||||
<div class="overflow-hidden rounded-xl bg-white/90 p-1.5 shadow-xl backdrop-blur-sm">
|
||||
<img
|
||||
src="/MONACOUSA-Flags_376x376.png"
|
||||
alt="Monaco USA"
|
||||
class="h-10 w-10 md:h-12 md:w-12 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h1 class="text-lg md:text-xl font-bold text-white drop-shadow-lg">
|
||||
Monaco <span class="text-monaco-300">USA</span>
|
||||
</h1>
|
||||
<p class="text-xs text-white/80">Americans in Monaco</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Wizard Container -->
|
||||
{@render children()}
|
||||
|
||||
<!-- Footer - Compact -->
|
||||
<p class="mt-3 md:mt-4 text-center text-xs text-white/60">
|
||||
© 2026 Monaco USA. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
350
src/routes/join/+page.server.ts
Normal file
350
src/routes/join/+page.server.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { supabaseAdmin } from '$lib/server/supabase';
|
||||
import { sendTemplatedEmail } from '$lib/server/email';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
|
||||
// If already logged in, check if onboarding is completed
|
||||
if (session) {
|
||||
// Get member profile to check onboarding status
|
||||
const { data: member } = await locals.supabase
|
||||
.from('members')
|
||||
.select('onboarding_completed_at')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
// Only redirect to dashboard if onboarding is completed
|
||||
if (member?.onboarding_completed_at) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
// Otherwise, let them continue the onboarding wizard
|
||||
}
|
||||
|
||||
// Get payment settings for the payment step
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'dues');
|
||||
|
||||
const paymentSettings: Record<string, string> = {};
|
||||
if (settings) {
|
||||
for (const s of settings) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
paymentSettings[s.setting_key.replace('payment_', '')] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Get default membership type for dues amount
|
||||
const { data: defaultType } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('annual_dues')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// If logged in but not completed onboarding, get member data
|
||||
let member = null;
|
||||
if (session) {
|
||||
const { data } = await locals.supabase
|
||||
.from('members')
|
||||
.select('id, first_name, email, member_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
member = data;
|
||||
}
|
||||
|
||||
return {
|
||||
paymentSettings,
|
||||
duesAmount: defaultType?.annual_dues || 150,
|
||||
session: session || null,
|
||||
member
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createAccount: async ({ request, locals, url }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract form fields
|
||||
const firstName = formData.get('first_name') as string;
|
||||
const lastName = formData.get('last_name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const phone = formData.get('phone') as string;
|
||||
const dateOfBirth = formData.get('date_of_birth') as string;
|
||||
const address = formData.get('address') as string;
|
||||
const nationalityString = formData.get('nationality') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirm_password') as string;
|
||||
const terms = formData.get('terms');
|
||||
|
||||
// Validation
|
||||
if (!firstName || firstName.length < 2) {
|
||||
return fail(400, { error: 'First name must be at least 2 characters', step: 2 });
|
||||
}
|
||||
|
||||
if (!lastName || lastName.length < 2) {
|
||||
return fail(400, { error: 'Last name must be at least 2 characters', step: 2 });
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return fail(400, { error: 'Please enter a valid email address', step: 2 });
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
return fail(400, { error: 'Phone number is required', step: 2 });
|
||||
}
|
||||
|
||||
if (!dateOfBirth) {
|
||||
return fail(400, { error: 'Date of birth is required', step: 2 });
|
||||
} else {
|
||||
// Check if 18+
|
||||
const birthDate = new Date(dateOfBirth);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
const dayDiff = today.getDate() - birthDate.getDate();
|
||||
const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age;
|
||||
|
||||
if (actualAge < 18) {
|
||||
return fail(400, { error: 'You must be at least 18 years old to join', step: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!address || address.length < 10) {
|
||||
return fail(400, { error: 'Please enter a complete address', step: 2 });
|
||||
}
|
||||
|
||||
const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
|
||||
if (nationality.length === 0) {
|
||||
return fail(400, { error: 'Please select at least one nationality', step: 2 });
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters', step: 2 });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, { error: 'Passwords do not match', step: 2 });
|
||||
}
|
||||
|
||||
if (!terms) {
|
||||
return fail(400, { error: 'You must accept the terms and conditions', step: 2 });
|
||||
}
|
||||
|
||||
// Create Supabase auth user
|
||||
const { data: authData, error: authError } = await locals.supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${url.origin}/join?verified=true`,
|
||||
data: {
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
if (authError.message.includes('already registered')) {
|
||||
return fail(400, {
|
||||
error: 'An account with this email already exists. Try signing in instead.',
|
||||
step: 2
|
||||
});
|
||||
}
|
||||
return fail(400, { error: authError.message, step: 2 });
|
||||
}
|
||||
|
||||
if (!authData.user) {
|
||||
return fail(500, { error: 'Failed to create account. Please try again.', step: 2 });
|
||||
}
|
||||
|
||||
// Get the pending membership status
|
||||
const { data: pendingStatus } = await locals.supabase
|
||||
.from('membership_statuses')
|
||||
.select('id')
|
||||
.eq('name', 'pending')
|
||||
.single();
|
||||
|
||||
// Get the default membership type
|
||||
const { data: defaultType } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('id')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
if (!pendingStatus?.id || !defaultType?.id) {
|
||||
console.error('Missing default status or type');
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
return fail(500, { error: 'System configuration error. Please contact support.', step: 2 });
|
||||
}
|
||||
|
||||
// Generate member ID
|
||||
const year = new Date().getFullYear();
|
||||
const { count } = await locals.supabase
|
||||
.from('members')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
const memberNumber = String((count || 0) + 1).padStart(4, '0');
|
||||
const memberId = `MUSA-${year}-${memberNumber}`;
|
||||
|
||||
// Create member profile
|
||||
const { error: memberError } = await locals.supabase.from('members').insert({
|
||||
id: authData.user.id,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email,
|
||||
phone,
|
||||
date_of_birth: dateOfBirth,
|
||||
address,
|
||||
nationality,
|
||||
member_id: memberId,
|
||||
role: 'member',
|
||||
membership_status_id: pendingStatus.id,
|
||||
membership_type_id: defaultType.id
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
console.error('Failed to create member profile:', memberError);
|
||||
try {
|
||||
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||
} catch (deleteError) {
|
||||
console.error('Failed to clean up auth user:', deleteError);
|
||||
}
|
||||
return fail(500, {
|
||||
error: 'Failed to create member profile. Please try again.',
|
||||
step: 2
|
||||
});
|
||||
}
|
||||
|
||||
// Sign in the user so they can continue the wizard
|
||||
const { error: signInError } = await locals.supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
console.error('Failed to sign in after account creation:', signInError);
|
||||
// Continue anyway - they can verify email and sign in later
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
step: 2,
|
||||
memberId,
|
||||
email
|
||||
};
|
||||
},
|
||||
|
||||
uploadPhoto: async ({ request, locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
if (!session) {
|
||||
return fail(401, { error: 'Not authenticated', step: 3 });
|
||||
}
|
||||
|
||||
// For now, just proceed to next step
|
||||
// Avatar upload can be handled via the profile page later
|
||||
// or we can add proper file handling here
|
||||
|
||||
return {
|
||||
success: true,
|
||||
step: 3
|
||||
};
|
||||
},
|
||||
|
||||
complete: async ({ locals }) => {
|
||||
const { session } = await locals.safeGetSession();
|
||||
if (!session) {
|
||||
return fail(401, { error: 'Not authenticated', step: 6 });
|
||||
}
|
||||
|
||||
// Set payment deadline (30 days from now)
|
||||
const paymentDeadline = new Date();
|
||||
paymentDeadline.setDate(paymentDeadline.getDate() + 30);
|
||||
|
||||
// Update member with onboarding completion
|
||||
const { error: updateError } = await locals.supabase
|
||||
.from('members')
|
||||
.update({
|
||||
payment_deadline: paymentDeadline.toISOString(),
|
||||
onboarding_completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', session.user.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update member:', updateError);
|
||||
return fail(500, { error: 'Failed to complete onboarding', step: 6 });
|
||||
}
|
||||
|
||||
// Get member data for email
|
||||
const { data: member } = await locals.supabase
|
||||
.from('members')
|
||||
.select('first_name, member_id, email')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
// Get payment settings
|
||||
const { data: settings } = await locals.supabase
|
||||
.from('app_settings')
|
||||
.select('setting_key, setting_value')
|
||||
.eq('category', 'dues');
|
||||
|
||||
const paymentSettings: Record<string, string> = {};
|
||||
if (settings) {
|
||||
for (const s of settings) {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
value = value.replace(/^"|"$/g, '');
|
||||
}
|
||||
paymentSettings[s.setting_key.replace('payment_', '')] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Get default membership dues amount
|
||||
const { data: defaultType } = await locals.supabase
|
||||
.from('membership_types')
|
||||
.select('annual_dues')
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
// Send welcome email with payment instructions
|
||||
if (member) {
|
||||
try {
|
||||
await sendTemplatedEmail(
|
||||
'onboarding_welcome',
|
||||
member.email,
|
||||
{
|
||||
first_name: member.first_name,
|
||||
member_id: member.member_id || 'N/A',
|
||||
amount: `€${defaultType?.annual_dues || 150}`,
|
||||
payment_deadline: paymentDeadline.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}),
|
||||
account_holder: paymentSettings.account_holder || 'Monaco USA',
|
||||
bank_name: paymentSettings.bank_name || 'Credit Foncier de Monaco',
|
||||
iban: paymentSettings.iban || 'Contact for details'
|
||||
},
|
||||
{
|
||||
recipientId: session.user.id,
|
||||
recipientName: `${member.first_name}`,
|
||||
sentBy: 'system'
|
||||
}
|
||||
);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
// Continue anyway - not critical
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
step: 6
|
||||
};
|
||||
}
|
||||
};
|
||||
773
src/routes/join/+page.svelte
Normal file
773
src/routes/join/+page.svelte
Normal file
@@ -0,0 +1,773 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { DatePicker, NationalitySelect, PhoneInput, CountrySelect } from '$lib/components/ui';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { cubicOut, cubicIn } from 'svelte/easing';
|
||||
import {
|
||||
Sparkles,
|
||||
Users,
|
||||
Calendar,
|
||||
FileText,
|
||||
CreditCard,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Upload,
|
||||
Check,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
ChevronRight
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
// Wizard state - start at step 3 if already logged in (resuming onboarding)
|
||||
let currentStep = $state(data.session ? 3 : 1);
|
||||
let direction = $state<'forward' | 'backward'>('forward');
|
||||
let loading = $state(false);
|
||||
|
||||
// Form data state
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
let email = $state('');
|
||||
let phone = $state('');
|
||||
let phoneCountryCode = $state('US');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let selectedNationalities = $state<string[]>([]);
|
||||
let dateOfBirth = $state<CalendarDate | undefined>(undefined);
|
||||
let street = $state('');
|
||||
let city = $state('');
|
||||
let residenceCountry = $state('MC');
|
||||
let termsAccepted = $state(false);
|
||||
|
||||
// Computed full address for database
|
||||
const fullAddress = $derived(`${street}, ${city}, ${residenceCountry}`);
|
||||
|
||||
// Avatar state
|
||||
let avatarFile = $state<File | null>(null);
|
||||
let avatarPreview = $state<string | null>(null);
|
||||
let avatarUploading = $state(false);
|
||||
|
||||
// Email verification state
|
||||
let emailVerified = $state(false);
|
||||
let verificationChecking = $state(false);
|
||||
let resendingEmail = $state(false);
|
||||
|
||||
// Member data (set after account creation or from existing session)
|
||||
let memberId = $state<string | null>(data.member?.member_id || null);
|
||||
let memberEmail = $state<string | null>(data.member?.email || null);
|
||||
|
||||
// Calculate max date for 18+ years old
|
||||
const todayDate = today(getLocalTimeZone());
|
||||
const maxDateOfBirth = new CalendarDate(
|
||||
todayDate.year - 18,
|
||||
todayDate.month,
|
||||
todayDate.day
|
||||
);
|
||||
|
||||
// Step definitions
|
||||
const steps = [
|
||||
{ num: 1, title: 'Welcome', icon: Sparkles },
|
||||
{ num: 2, title: 'Your Info', icon: Users },
|
||||
{ num: 3, title: 'Photo', icon: Camera },
|
||||
{ num: 4, title: 'Tour', icon: Calendar },
|
||||
{ num: 5, title: 'Verify', icon: Mail },
|
||||
{ num: 6, title: 'Complete', icon: CreditCard }
|
||||
];
|
||||
|
||||
// Benefits for welcome page
|
||||
const benefits = [
|
||||
{ icon: Calendar, title: 'Exclusive Events', description: 'Access member-only social gatherings, galas, and networking events' },
|
||||
{ icon: Users, title: 'Vibrant Community', description: 'Meet fellow Americans at Monaco USA events and build lasting connections' },
|
||||
{ icon: FileText, title: 'Member Resources', description: 'Documents, guides, and support for life in Monaco' },
|
||||
{ icon: Sparkles, title: 'Cultural Exchange', description: 'Bridge American and Monegasque communities' }
|
||||
];
|
||||
|
||||
// Platform tour features
|
||||
const features = [
|
||||
{ icon: Sparkles, title: 'Dashboard', description: 'Your personalized hub with dues status, upcoming events, and community updates', color: 'bg-monaco-100 text-monaco-600' },
|
||||
{ icon: Calendar, title: 'Events', description: 'Browse and RSVP to exclusive member events, from galas to casual meetups', color: 'bg-blue-100 text-blue-600' },
|
||||
{ icon: Users, title: 'Community', description: 'Meet fellow Americans at our events and become part of our vibrant community', color: 'bg-green-100 text-green-600' },
|
||||
{ icon: FileText, title: 'Documents', description: 'Access important documents, meeting minutes, and member resources', color: 'bg-purple-100 text-purple-600' }
|
||||
];
|
||||
|
||||
function nextStep() {
|
||||
direction = 'forward';
|
||||
currentStep = Math.min(currentStep + 1, 6);
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
direction = 'backward';
|
||||
currentStep = Math.max(currentStep - 1, 1);
|
||||
}
|
||||
|
||||
function handleAvatarSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
avatarFile = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
avatarPreview = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEmailVerification() {
|
||||
verificationChecking = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/check-verification');
|
||||
const result = await response.json();
|
||||
emailVerified = result.verified;
|
||||
if (emailVerified) {
|
||||
nextStep();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check verification:', error);
|
||||
} finally {
|
||||
verificationChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resendVerificationEmail() {
|
||||
resendingEmail = true;
|
||||
try {
|
||||
await fetch('/api/auth/resend-verification', { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Failed to resend email:', error);
|
||||
} finally {
|
||||
resendingEmail = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form results
|
||||
$effect(() => {
|
||||
if (form?.success && form?.step === 2) {
|
||||
// Account created successfully
|
||||
memberId = form.memberId;
|
||||
memberEmail = form.email;
|
||||
nextStep();
|
||||
}
|
||||
if (form?.success && form?.step === 3) {
|
||||
// Photo uploaded
|
||||
nextStep();
|
||||
}
|
||||
if (form?.success && form?.step === 6) {
|
||||
// Onboarding complete
|
||||
goto('/dashboard');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Join Monaco USA</title>
|
||||
<meta name="description" content="Join the Monaco USA community - Americans living in and connected to Monaco" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Step Indicator -->
|
||||
<div class="mb-2 sm:mb-3 md:mb-4">
|
||||
<div class="flex items-center justify-center gap-1 sm:gap-2">
|
||||
{#each steps as step}
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="flex h-6 w-6 sm:h-8 sm:w-8 items-center justify-center rounded-full text-[10px] sm:text-xs font-semibold transition-all duration-300
|
||||
{currentStep === step.num
|
||||
? 'bg-white text-monaco-600 shadow-lg scale-110'
|
||||
: currentStep > step.num
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-white/20 text-white/60'}"
|
||||
>
|
||||
{#if currentStep > step.num}
|
||||
<Check class="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
{:else}
|
||||
{step.num}
|
||||
{/if}
|
||||
</div>
|
||||
{#if step.num < 6}
|
||||
<div
|
||||
class="mx-0.5 sm:mx-1 h-0.5 w-2 sm:w-4 hidden sm:block transition-all duration-300
|
||||
{currentStep > step.num ? 'bg-green-500' : 'bg-white/20'}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-1.5 text-center text-xs sm:text-sm text-white/80">
|
||||
Step {currentStep} of 6: {steps[currentStep - 1].title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Wizard Card -->
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
{#key currentStep}
|
||||
<div
|
||||
in:fly={{ x: direction === 'forward' ? 60 : -60, duration: 400, easing: cubicOut }}
|
||||
out:fly={{ x: direction === 'forward' ? -60 : 60, duration: 300, easing: cubicIn }}
|
||||
>
|
||||
<!-- Step 1: Welcome -->
|
||||
{#if currentStep === 1}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl md:text-2xl font-bold text-slate-900">Welcome to Monaco USA</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Join our vibrant community of Americans in Monaco</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-5 grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{#each benefits as benefit, i}
|
||||
<div
|
||||
class="rounded-lg border border-slate-100 bg-slate-50 p-3 transition-all hover:border-monaco-200 hover:bg-monaco-50"
|
||||
in:fly={{ y: 20, delay: i * 100, duration: 300 }}
|
||||
>
|
||||
<div class="mb-1.5 flex h-8 w-8 items-center justify-center rounded-lg bg-monaco-100">
|
||||
<svelte:component this={benefit.icon} class="h-4 w-4 text-monaco-600" />
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">{benefit.title}</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-600">{benefit.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-5">
|
||||
<Button variant="monaco" size="default" class="w-full" onclick={nextStep}>
|
||||
Get Started
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-center text-sm text-slate-500">
|
||||
Already have an account?
|
||||
<a href="/login" class="font-medium text-monaco-600 hover:underline">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 2: Your Information -->
|
||||
{#if currentStep === 2}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-monaco-100">
|
||||
<Users class="h-6 w-6 text-monaco-600" />
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-bold text-slate-900">Your Information</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">Tell us about yourself to create your account</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mt-4">
|
||||
<FormMessage type="error" message={form.error} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createAccount"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4 space-y-3"
|
||||
>
|
||||
<!-- Name Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="first_name" class="text-sm font-medium text-slate-700">
|
||||
First name <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={firstName}
|
||||
class="h-10"
|
||||
autocomplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="last_name" class="text-sm font-medium text-slate-700">
|
||||
Last name <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={lastName}
|
||||
class="h-10"
|
||||
autocomplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="space-y-1">
|
||||
<Label for="email" class="text-sm font-medium text-slate-700">
|
||||
Email <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={email}
|
||||
class="h-10"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Phone <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<PhoneInput
|
||||
bind:value={phone}
|
||||
bind:countryCode={phoneCountryCode}
|
||||
disabled={loading}
|
||||
placeholder="Phone number"
|
||||
name="phone"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nationality -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Nationality <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<NationalitySelect
|
||||
bind:value={selectedNationalities}
|
||||
disabled={loading}
|
||||
placeholder="Search and select nationalities..."
|
||||
name="nationality"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date of Birth -->
|
||||
<div class="space-y-1">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Date of birth <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
bind:value={dateOfBirth}
|
||||
maxValue={maxDateOfBirth}
|
||||
disabled={loading}
|
||||
placeholder="Select your birth date"
|
||||
name="date_of_birth"
|
||||
/>
|
||||
<p class="text-xs text-slate-500">You must be at least 18 years old.</p>
|
||||
</div>
|
||||
|
||||
<!-- Street Address -->
|
||||
<div class="space-y-1">
|
||||
<Label for="street" class="text-sm font-medium text-slate-700">
|
||||
Street address <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="street"
|
||||
name="street"
|
||||
type="text"
|
||||
placeholder="123 Avenue Princesse Grace"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={street}
|
||||
class="h-10"
|
||||
autocomplete="street-address"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- City & Country of Residence -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="city" class="text-sm font-medium text-slate-700">
|
||||
City <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
type="text"
|
||||
placeholder="Monaco"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={city}
|
||||
class="h-10"
|
||||
autocomplete="address-level2"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-sm font-medium text-slate-700">
|
||||
Country of residence <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<CountrySelect
|
||||
bind:value={residenceCountry}
|
||||
disabled={loading}
|
||||
placeholder="Select country..."
|
||||
name="residence_country"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden full address for database -->
|
||||
<input type="hidden" name="address" value={fullAddress} />
|
||||
|
||||
<!-- Password -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label for="password" class="text-sm font-medium text-slate-700">
|
||||
Password <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={password}
|
||||
minlength={8}
|
||||
class="h-10"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="confirm_password" class="text-sm font-medium text-slate-700">
|
||||
Confirm <span class="text-monaco-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
disabled={loading}
|
||||
bind:value={confirmPassword}
|
||||
minlength={8}
|
||||
class="h-10"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms -->
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
name="terms"
|
||||
required
|
||||
bind:checked={termsAccepted}
|
||||
class="mt-1 h-5 w-5 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500 transition-colors"
|
||||
/>
|
||||
<label for="terms" class="text-sm text-slate-600">
|
||||
I agree to the
|
||||
<a href="/terms" target="_blank" class="text-monaco-600 hover:underline">Terms of Service</a>
|
||||
and
|
||||
<a href="/privacy" target="_blank" class="text-monaco-600 hover:underline">Privacy Policy</a>.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" size="lg" class="flex-1" onclick={prevStep} disabled={loading}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" variant="monaco" size="lg" class="flex-1" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Creating...
|
||||
{:else}
|
||||
Continue
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: Profile Photo -->
|
||||
{#if currentStep === 3}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-lg font-bold text-slate-900">Add a Profile Photo</h2>
|
||||
<p class="text-sm text-slate-600">Help other members recognize you (optional)</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-col items-center">
|
||||
<div class="relative">
|
||||
{#if avatarPreview}
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Profile preview"
|
||||
class="h-32 w-32 rounded-full object-cover shadow-lg"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-32 w-32 items-center justify-center rounded-full bg-slate-100">
|
||||
<Camera class="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
{/if}
|
||||
<label
|
||||
class="absolute bottom-0 right-0 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-monaco-600 text-white shadow-lg transition-colors hover:bg-monaco-700"
|
||||
>
|
||||
<Upload class="h-5 w-5" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden"
|
||||
onchange={handleAvatarSelect}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
JPEG, PNG, or WebP. Max 5MB.
|
||||
</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/uploadPhoto"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
avatarUploading = true;
|
||||
return async ({ update }) => {
|
||||
avatarUploading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-6 w-full"
|
||||
>
|
||||
{#if avatarFile}
|
||||
<input type="hidden" name="hasAvatar" value="true" />
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button type="button" variant="outline" size="lg" class="flex-1" onclick={prevStep}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
class="flex-1"
|
||||
onclick={nextStep}
|
||||
>
|
||||
Skip
|
||||
<ChevronRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{#if avatarFile}
|
||||
<Button type="submit" variant="monaco" size="lg" class="flex-1" disabled={avatarUploading}>
|
||||
{#if avatarUploading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Uploading...
|
||||
{:else}
|
||||
Continue
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: Platform Tour -->
|
||||
{#if currentStep === 4}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-lg font-bold text-slate-900">Explore Your New Home</h2>
|
||||
<p class="text-sm text-slate-600">Here's what you'll have access to as a member</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each features as feature, i}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg border border-slate-100 p-3 transition-all hover:border-slate-200 hover:bg-slate-50"
|
||||
in:fly={{ x: 50, delay: i * 100, duration: 300 }}
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg {feature.color}">
|
||||
<svelte:component this={feature.icon} class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">{feature.title}</h3>
|
||||
<p class="text-xs text-slate-600">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<Button type="button" variant="outline" size="default" class="flex-1" onclick={prevStep}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button type="button" variant="monaco" size="default" class="flex-1" onclick={nextStep}>
|
||||
Continue
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 5: Verify Email -->
|
||||
{#if currentStep === 5}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-monaco-100">
|
||||
<Mail class="h-6 w-6 text-monaco-600" />
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-bold text-slate-900">Verify Your Email</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
We sent a verification link to<br />
|
||||
<strong class="text-slate-900">{memberEmail || email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-slate-50 p-4 text-center">
|
||||
<p class="text-sm text-slate-600">
|
||||
Click the link in your email to verify your address, then click the button below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="monaco"
|
||||
size="default"
|
||||
class="w-full"
|
||||
onclick={checkEmailVerification}
|
||||
disabled={verificationChecking}
|
||||
>
|
||||
{#if verificationChecking}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Checking...
|
||||
{:else}
|
||||
I've Verified My Email
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="default"
|
||||
class="w-full"
|
||||
onclick={resendVerificationEmail}
|
||||
disabled={resendingEmail}
|
||||
>
|
||||
{#if resendingEmail}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Sending...
|
||||
{:else}
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
Resend Verification Email
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button type="button" variant="outline" size="default" class="w-full" onclick={prevStep}>
|
||||
<ArrowLeft class="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 6: Payment & Complete -->
|
||||
{#if currentStep === 6}
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<Check class="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h2 class="mt-2 text-lg font-bold text-slate-900">You're Almost There!</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Complete your membership by paying your annual dues within 30 days
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-slate-50 p-4">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-slate-500">Annual Membership</p>
|
||||
<p class="text-2xl font-bold text-monaco-600">€{data?.duesAmount || '150'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<h3 class="text-sm font-semibold text-blue-900">Bank Transfer Details</h3>
|
||||
<div class="mt-2 space-y-1.5 text-sm">
|
||||
<p class="flex justify-between">
|
||||
<span class="text-blue-700">Account Holder:</span>
|
||||
<span class="font-medium text-blue-900">{data?.paymentSettings?.account_holder || 'Monaco USA'}</span>
|
||||
</p>
|
||||
<p class="flex justify-between">
|
||||
<span class="text-blue-700">Bank:</span>
|
||||
<span class="font-medium text-blue-900">{data?.paymentSettings?.bank_name || 'Credit Foncier de Monaco'}</span>
|
||||
</p>
|
||||
<p class="flex justify-between">
|
||||
<span class="text-blue-700">IBAN:</span>
|
||||
<span class="font-mono font-medium text-blue-900">{data?.paymentSettings?.iban || 'MC58...'}</span>
|
||||
</p>
|
||||
<p class="flex justify-between">
|
||||
<span class="text-blue-700">Reference:</span>
|
||||
<span class="font-mono font-medium text-blue-900">{memberId || 'MUSA-2026-XXXX'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg bg-amber-50 border border-amber-200 p-3">
|
||||
<h4 class="text-sm font-medium text-amber-900">What Happens Next?</h4>
|
||||
<ul class="mt-1.5 space-y-0.5 text-xs text-amber-800">
|
||||
<li>• Check your email for confirmation</li>
|
||||
<li>• Make your bank transfer within 30 days</li>
|
||||
<li>• We'll activate your account once payment is received</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/complete"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4"
|
||||
>
|
||||
<Button type="submit" variant="monaco" size="default" class="w-full" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Completing...
|
||||
{:else}
|
||||
Go to Dashboard
|
||||
<ArrowRight class="ml-2 h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
12
src/routes/logout/+server.ts
Normal file
12
src/routes/logout/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ locals }) => {
|
||||
await locals.supabase.auth.signOut();
|
||||
throw redirect(303, '/login');
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
await locals.supabase.auth.signOut();
|
||||
throw redirect(303, '/login');
|
||||
};
|
||||
105
src/routes/public/events/[id]/+page.server.ts
Normal file
105
src/routes/public/events/[id]/+page.server.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
// Fetch the event (only public events)
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.eq('visibility', 'public')
|
||||
.eq('status', 'published')
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
throw error(404, 'Event not found or not publicly accessible');
|
||||
}
|
||||
|
||||
return {
|
||||
event
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
rsvp: async ({ request, locals, params }) => {
|
||||
const formData = await request.formData();
|
||||
const fullName = (formData.get('full_name') as string)?.trim();
|
||||
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
||||
const phone = (formData.get('phone') as string)?.trim() || null;
|
||||
const guestCount = parseInt(formData.get('guest_count') as string) || 0;
|
||||
|
||||
// Validation
|
||||
if (!fullName || fullName.length < 2) {
|
||||
return fail(400, { error: 'Please enter your full name' });
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { error: 'Please enter your email address' });
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
// Fetch the event to check capacity
|
||||
const { data: event } = await locals.supabase
|
||||
.from('events_with_counts')
|
||||
.select('*')
|
||||
.eq('id', params.id)
|
||||
.eq('visibility', 'public')
|
||||
.eq('status', 'published')
|
||||
.single();
|
||||
|
||||
if (!event) {
|
||||
return fail(404, { error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Validate guest count
|
||||
if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
|
||||
return fail(400, {
|
||||
error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this email already RSVP'd
|
||||
const { data: existingRsvp } = await locals.supabase
|
||||
.from('event_rsvps_public')
|
||||
.select('id')
|
||||
.eq('event_id', params.id)
|
||||
.eq('email', email)
|
||||
.single();
|
||||
|
||||
if (existingRsvp) {
|
||||
return fail(400, { error: 'This email has already registered for this event' });
|
||||
}
|
||||
|
||||
// Check if event is full
|
||||
const totalAttending = event.total_attendees + 1 + guestCount;
|
||||
const isFull = event.max_attendees && totalAttending > event.max_attendees;
|
||||
|
||||
// Create public RSVP
|
||||
const { error: rsvpError } = await locals.supabase.from('event_rsvps_public').insert({
|
||||
event_id: params.id,
|
||||
full_name: fullName,
|
||||
email,
|
||||
phone,
|
||||
status: isFull ? 'waitlist' : 'confirmed',
|
||||
guest_count: guestCount,
|
||||
payment_status: event.is_paid ? 'pending' : 'not_required',
|
||||
payment_amount: event.is_paid ? event.non_member_price * (1 + guestCount) : null
|
||||
});
|
||||
|
||||
if (rsvpError) {
|
||||
console.error('Public RSVP error:', rsvpError);
|
||||
return fail(500, { error: 'Failed to submit RSVP. Please try again.' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: isFull
|
||||
? 'You have been added to the waitlist. We will notify you if a spot opens up.'
|
||||
: 'Registration successful! We look forward to seeing you at the event.'
|
||||
};
|
||||
}
|
||||
};
|
||||
369
src/routes/public/events/[id]/+page.svelte
Normal file
369
src/routes/public/events/[id]/+page.svelte
Normal file
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { FormMessage, LoadingSpinner } from '$lib/components/auth';
|
||||
import AddToCalendarButton from '$lib/components/ui/AddToCalendarButton.svelte';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
MapPin,
|
||||
Users,
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const { event } = data;
|
||||
|
||||
let loading = $state(false);
|
||||
let guestCount = $state(0);
|
||||
|
||||
const eventDate = new Date(event.start_datetime);
|
||||
const eventEndDate = new Date(event.end_datetime);
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
const spotsRemaining = event.max_attendees
|
||||
? Math.max(0, event.max_attendees - event.total_attendees)
|
||||
: null;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{event.title} | Monaco USA</title>
|
||||
<meta name="description" content={event.description || `Join us for ${event.title}`} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative min-h-screen bg-slate-50">
|
||||
<!-- Hero Header -->
|
||||
<div class="relative bg-gradient-to-br from-slate-900 via-slate-800 to-monaco-900 py-16 text-white">
|
||||
<div class="absolute inset-0 bg-[url('/monaco_high_res.jpg')] bg-cover bg-center opacity-20"></div>
|
||||
<div class="relative mx-auto max-w-4xl px-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<img src="/MONACOUSA-Flags_376x376.png" alt="Monaco USA" class="h-12 w-12 rounded-lg bg-white/90 p-1" />
|
||||
<span class="text-xl font-bold">Monaco <span class="text-monaco-300">USA</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if event.event_type_name}
|
||||
<span
|
||||
class="inline-block rounded-full px-3 py-1 text-sm font-medium"
|
||||
style="background-color: {event.event_type_color}40; color: {event.event_type_color}"
|
||||
>
|
||||
{event.event_type_name}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<h1 class="mt-4 text-4xl font-bold md:text-5xl">{event.title}</h1>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-6 text-white/90">
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-5 w-5 text-monaco-300" />
|
||||
<span>{formatDate(eventDate)}</span>
|
||||
</div>
|
||||
{#if !event.all_day}
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-5 w-5 text-monaco-300" />
|
||||
<span>{formatTime(eventDate)} - {formatTime(eventEndDate)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if event.location}
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-5 w-5 text-monaco-300" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="mx-auto max-w-4xl px-4 py-12">
|
||||
<div class="grid gap-8 lg:grid-cols-3">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- Description -->
|
||||
{#if event.description}
|
||||
<div class="rounded-2xl bg-white p-8 shadow-sm">
|
||||
<h2 class="mb-4 text-xl font-semibold text-slate-900">About This Event</h2>
|
||||
<div class="prose prose-slate max-w-none">
|
||||
{@html event.description.replace(/\n/g, '<br />')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="rounded-2xl bg-white p-8 shadow-sm">
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Event Details</h2>
|
||||
<AddToCalendarButton
|
||||
eventId={event.id}
|
||||
eventTitle={event.title}
|
||||
startDatetime={event.start_datetime}
|
||||
endDatetime={event.end_datetime}
|
||||
location={event.location}
|
||||
description={event.description}
|
||||
isPublic={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100">
|
||||
<Calendar class="h-5 w-5 text-monaco-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Date & Time</p>
|
||||
<p class="text-slate-600">
|
||||
{formatDate(eventDate)}
|
||||
{#if !event.all_day}
|
||||
<br />{formatTime(eventDate)} - {formatTime(eventEndDate)} ({event.timezone})
|
||||
{:else}
|
||||
<br />All day event
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if event.location}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100">
|
||||
<MapPin class="h-5 w-5 text-monaco-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Location</p>
|
||||
<p class="text-slate-600">{event.location}</p>
|
||||
{#if event.location_url}
|
||||
<a
|
||||
href={event.location_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1 inline-flex items-center gap-1 text-sm text-monaco-600 hover:underline"
|
||||
>
|
||||
View on map <ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if event.is_paid}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||
<DollarSign class="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Price</p>
|
||||
<p class="text-slate-600">
|
||||
Non-members: {formatCurrency(event.non_member_price)}
|
||||
{#if event.pricing_notes}
|
||||
<br /><span class="text-sm">{event.pricing_notes}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<Users class="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-900">Attendance</p>
|
||||
<p class="text-slate-600">
|
||||
{event.total_attendees} registered
|
||||
{#if event.max_attendees}
|
||||
/ {event.max_attendees} capacity
|
||||
{/if}
|
||||
</p>
|
||||
{#if event.waitlist_count > 0}
|
||||
<p class="text-sm text-amber-600">{event.waitlist_count} on waitlist</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP Sidebar -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="sticky top-8 rounded-2xl bg-white p-6 shadow-sm">
|
||||
{#if form?.success}
|
||||
<div class="text-center">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle class="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">You're Registered!</h3>
|
||||
<p class="mt-2 text-slate-600">{form.success}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<h3 class="text-xl font-semibold text-slate-900">Register for This Event</h3>
|
||||
|
||||
{#if spotsRemaining !== null && spotsRemaining <= 5}
|
||||
<div class="mt-4 flex items-center gap-2 rounded-lg bg-amber-50 p-3 text-amber-700">
|
||||
<AlertCircle class="h-5 w-5 shrink-0" />
|
||||
<span class="text-sm">
|
||||
{#if spotsRemaining === 0}
|
||||
Event is full - Join waitlist
|
||||
{:else}
|
||||
Only {spotsRemaining} spot{spotsRemaining === 1 ? '' : 's'} left!
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mt-4">
|
||||
<FormMessage type="error" message={form.error} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rsvp"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-6 space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="full_name">Full Name *</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
type="text"
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="John Smith"
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="john@example.com"
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="phone">Phone (optional)</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
disabled={loading}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
class="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if event.max_guests_per_member !== 0}
|
||||
<div class="space-y-2">
|
||||
<Label for="guest_count">Additional Guests</Label>
|
||||
<select
|
||||
id="guest_count"
|
||||
name="guest_count"
|
||||
bind:value={guestCount}
|
||||
disabled={loading}
|
||||
class="h-11 w-full rounded-lg border border-slate-200 px-3 focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||
>
|
||||
{#each Array(Math.min((event.max_guests_per_member || 5) + 1, 6)) as _, i}
|
||||
<option value={i}>{i} guest{i !== 1 ? 's' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if event.is_paid}
|
||||
<div class="rounded-lg bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-600">Total</span>
|
||||
<span class="text-xl font-semibold text-slate-900">
|
||||
{formatCurrency(event.non_member_price * (1 + guestCount))}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">Payment details will be provided after registration</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="monaco"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<LoadingSpinner size="sm" class="mr-2" />
|
||||
Registering...
|
||||
{:else if spotsRemaining === 0}
|
||||
Join Waitlist
|
||||
{:else}
|
||||
Register Now
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<p class="text-center text-xs text-slate-500">
|
||||
Already a Monaco USA member?
|
||||
<a href="/login" class="text-monaco-600 hover:underline">Sign in</a>
|
||||
to RSVP with your account.
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-slate-200 bg-white py-8">
|
||||
<div class="mx-auto max-w-4xl px-4 text-center">
|
||||
<p class="text-sm text-slate-500">
|
||||
© 2026 Monaco USA. All rights reserved.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
<a href="/" class="text-monaco-600 hover:underline">Home</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="/login" class="text-monaco-600 hover:underline">Member Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
Reference in New Issue
Block a user