commit e7338d1a70a7be733871ae375e68865bd3e8bd66
Author: Matt
Date: Sun Jan 25 02:19:49 2026 +0100
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
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..10254b3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,47 @@
+# Dependencies
+node_modules
+.pnpm-store
+
+# Build output
+build
+.svelte-kit
+
+# Environment files (we pass these at runtime)
+.env
+.env.*
+!.env.example
+
+# Git
+.git
+.gitignore
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+
+# Test
+coverage
+.nyc_output
+
+# Docker
+Dockerfile*
+docker-compose*.yml
+.dockerignore
+
+# Supabase local
+supabase/.temp
+supabase/.branches
+
+# Misc
+*.md
+LICENSE
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2de8f49
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,89 @@
+# Monaco USA Portal - Docker Environment Configuration
+# ===================================================
+# Copy this file to .env and configure your values
+
+# ===========================================
+# POSTGRES DATABASE
+# ===========================================
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=change-this-to-a-secure-password
+POSTGRES_DB=postgres
+POSTGRES_PORT=5435
+
+# ===========================================
+# JWT CONFIGURATION
+# ===========================================
+# IMPORTANT: Generate a new secret for production!
+# Use: openssl rand -base64 32
+JWT_SECRET=generate-a-new-secret-at-least-32-characters
+JWT_EXPIRY=3600
+
+# ===========================================
+# API KEYS
+# ===========================================
+# Generate these at: https://supabase.com/docs/guides/self-hosting#api-keys
+# They must be signed with your JWT_SECRET
+
+# Anonymous key - for public access (limited permissions)
+ANON_KEY=your-generated-anon-key
+
+# Service role key - for admin access (full permissions, keep secret!)
+SERVICE_ROLE_KEY=your-generated-service-role-key
+
+# ===========================================
+# URLS & PORTS
+# ===========================================
+KONG_HTTP_PORT=7455
+KONG_HTTPS_PORT=7456
+STUDIO_PORT=7454
+PORTAL_PORT=7453
+
+SITE_URL=http://localhost:7453
+API_EXTERNAL_URL=http://localhost:7455
+SUPABASE_PUBLIC_URL=http://localhost:7455
+
+PUBLIC_SUPABASE_URL=http://localhost:7455
+PUBLIC_SUPABASE_ANON_KEY=same-as-anon-key-above
+
+# Service role key for admin operations (server-side only)
+SUPABASE_SERVICE_ROLE_KEY=same-as-service-role-key-above
+
+# ===========================================
+# AUTH CONFIGURATION
+# ===========================================
+DISABLE_SIGNUP=false
+ENABLE_EMAIL_AUTOCONFIRM=true
+ADDITIONAL_REDIRECT_URLS=http://localhost:7453/auth/callback
+
+# ===========================================
+# SMTP EMAIL (Optional)
+# ===========================================
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASS=
+SMTP_ADMIN_EMAIL=noreply@example.org
+SMTP_SENDER_NAME=Monaco USA
+
+MAILER_URLPATHS_INVITE=/auth/verify
+MAILER_URLPATHS_CONFIRMATION=/auth/verify
+MAILER_URLPATHS_RECOVERY=/auth/verify
+MAILER_URLPATHS_EMAIL_CHANGE=/auth/verify
+RATE_LIMIT_EMAIL_SENT=100
+
+# ===========================================
+# REALTIME
+# ===========================================
+SECRET_KEY_BASE=generate-a-new-secret-key-base
+
+# ===========================================
+# POSTGREST
+# ===========================================
+PGRST_DB_SCHEMAS=public,storage,graphql_public
+
+# ===========================================
+# SVELTEKIT CONFIGURATION
+# ===========================================
+# Body size limit for file uploads (avatars, documents)
+# 50MB = 52428800 bytes
+BODY_SIZE_LIMIT=52428800
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..34e88e6
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -0,0 +1,130 @@
+# Gitea Actions - Monaco USA Portal Build & Deploy
+# This workflow builds and optionally deploys the portal
+#
+# Triggers:
+# - Push to main branch
+# - Pull requests to main
+# - Manual trigger (workflow_dispatch)
+#
+# Required Secrets (configure in Gitea repo settings):
+# - DEPLOY_HOST: Production server hostname/IP
+# - DEPLOY_USER: SSH username
+# - DEPLOY_KEY: SSH private key for deployment
+# - DEPLOY_PATH: Path to project on server (e.g., /opt/monacousa-portal)
+
+name: Build and Deploy
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ deploy:
+ description: 'Deploy to production'
+ required: false
+ default: 'false'
+
+jobs:
+ # =============================================
+ # Build Job - Builds Docker image
+ # =============================================
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: false
+ load: true
+ tags: monacousa-portal:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org
+ PUBLIC_SUPABASE_ANON_KEY=placeholder
+ SUPABASE_SERVICE_ROLE_KEY=placeholder
+
+ - name: Test Docker image starts
+ run: |
+ docker run -d --name test-portal \
+ -e PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org \
+ -e PUBLIC_SUPABASE_ANON_KEY=placeholder \
+ monacousa-portal:${{ github.sha }}
+ sleep 5
+ docker logs test-portal
+ docker stop test-portal
+
+ # =============================================
+ # Lint Job - Code quality checks
+ # =============================================
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci --legacy-peer-deps
+
+ - name: Run Svelte check
+ run: npm run check || true
+
+ - name: Run ESLint
+ run: npm run lint || true
+
+ # =============================================
+ # Deploy Job - Deploys to production server
+ # =============================================
+ deploy:
+ runs-on: ubuntu-latest
+ needs: [build, lint]
+ if: |
+ (github.event_name == 'push' && github.ref == 'refs/heads/main') ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'true')
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Deploy to production
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: ${{ secrets.DEPLOY_USER }}
+ key: ${{ secrets.DEPLOY_KEY }}
+ script: |
+ cd ${{ secrets.DEPLOY_PATH }}
+ git pull origin main
+ ./deploy.sh update
+ echo "Deployment completed at $(date)"
+
+ - name: Notify deployment success
+ if: success()
+ run: |
+ echo "Successfully deployed to production!"
+ echo "Commit: ${{ github.sha }}"
+ echo "Branch: ${{ github.ref_name }}"
+
+ - name: Notify deployment failure
+ if: failure()
+ run: |
+ echo "Deployment failed!"
+ echo "Check logs for details."
+ exit 1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..6f79650
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,3710 @@
+# Monaco USA Portal 2026 - Complete Rebuild
+
+## Project Overview
+Rebuild the Monaco USA member portal from scratch in `monacousa-portal-2026/` with modern architecture, beautiful UI, and improved functionality.
+
+---
+
+# DETAILED FEATURE SPECIFICATIONS
+
+## 1. MEMBER SYSTEM (Detailed)
+
+### 1.1 Member ID Format
+- **Format**: `MUSA-XXXX` (sequential 4-digit number)
+- **Examples**: MUSA-0001, MUSA-0042, MUSA-1234
+- **Auto-generated** on member creation
+- **Immutable** once assigned
+- **Unique constraint** in database
+
+### 1.2 Membership Statuses (Admin-Configurable)
+Admin can create, edit, and delete statuses via Settings.
+
+**Default Statuses (seeded on first run):**
+| Status | Color | Description | Is Default |
+|--------|-------|-------------|------------|
+| `pending` | Yellow | New member, awaiting dues payment | Yes (for new signups) |
+| `active` | Green | Dues paid, full access | No |
+| `inactive` | Gray | Lapsed membership or suspended | No |
+| `expired` | Red | Membership terminated | No |
+
+**Status Configuration Table:**
+```sql
+CREATE TABLE public.membership_statuses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#6b7280', -- Tailwind gray-500
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE, -- Used for new signups
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+```
+
+### 1.3 Roles/Tiers
+**Fixed 3-tier system (not configurable):**
+| Role | Access Level | Capabilities |
+|------|--------------|--------------|
+| `member` | Basic | View own profile, events, pay dues |
+| `board` | Elevated | + Member directory, record payments, manage events |
+| `admin` | Full | + User management, system settings, all data |
+
+### 1.4 Required Member Fields
+All fields marked as required during signup:
+
+| Field | Type | Validation | Notes |
+|-------|------|------------|-------|
+| `first_name` | Text | Min 2 chars | Required |
+| `last_name` | Text | Min 2 chars | Required |
+| `email` | Email | Valid email format | Required, unique |
+| `phone` | Text | International format | Required |
+| `date_of_birth` | Date | Must be 18+ years old | Required |
+| `address` | Text | Min 10 chars | Required |
+| `nationality` | Array | At least 1 country | Required, multiple allowed |
+
+### 1.5 Optional Member Fields
+| Field | Type | Notes |
+|-------|------|-------|
+| `avatar_url` | Text | Supabase Storage path |
+| `membership_type_id` | UUID | Links to membership_types table |
+| `notes` | Text | Admin-only notes about member |
+
+### 1.6 Nationality Handling
+- **Multiple nationalities allowed**
+- Stored as PostgreSQL `TEXT[]` array
+- Uses ISO 3166-1 alpha-2 country codes: `['FR', 'US', 'MC']`
+- UI shows country flags + names
+- Searchable/filterable in directory
+
+### 1.7 Profile Features
+- **Profile photo**: Upload via Supabase Storage
+ - Max size: 5MB
+ - Formats: JPG, PNG, WebP
+ - Auto-resized to 256x256
+ - Stored at: `avatars/{member_id}/profile.{ext}`
+- **No bio field** (simplified profile)
+- Members can edit: name, phone, address, nationality, photo
+
+### 1.8 Member Directory
+**Visibility controlled by admin settings:**
+
+```sql
+CREATE TABLE public.directory_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ field_name TEXT NOT NULL UNIQUE,
+ visible_to_members BOOLEAN DEFAULT FALSE,
+ visible_to_board BOOLEAN DEFAULT TRUE,
+ visible_to_admin BOOLEAN DEFAULT TRUE,
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default visibility settings
+INSERT INTO directory_settings (field_name, visible_to_members, visible_to_board) VALUES
+ ('first_name', true, true),
+ ('last_name', true, true),
+ ('avatar_url', true, true),
+ ('nationality', true, true),
+ ('email', false, true),
+ ('phone', false, true),
+ ('address', false, true),
+ ('date_of_birth', false, true),
+ ('member_since', true, true),
+ ('membership_status', false, true);
+```
+
+### 1.9 Member Signup Flow
+```
+┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+│ /signup │────▶│ Create Auth │────▶│ Email Verify│
+│ Form │ │ User + Member│ │ Link Sent │
+└─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐ ┌─────────────┐
+ │ Status = │────▶│ Wait for │
+ │ 'pending' │ │ Dues Payment│
+ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ Board/Admin │
+ │ Records Dues│
+ └─────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ Status = │
+ │ 'active' │
+ └─────────────┘
+```
+
+**Key Points:**
+- Email verification required
+- Status starts as `pending`
+- Member gains `active` status ONLY when first dues payment recorded
+- Pending members can log in but see limited dashboard
+
+### 1.10 Admin Member Management
+**Two ways to add members:**
+
+**Option A: Direct Add**
+1. Admin fills out member form
+2. Admin sets temporary password OR sends password setup email
+3. Member record created with chosen status
+4. Member can log in immediately
+
+**Option B: Invite**
+1. Admin enters email + basic info
+2. System sends invitation email with signup link
+3. Invitee completes signup form
+4. Status set based on invite settings
+
+### 1.11 Membership Types (Admin-Configurable)
+Admin can create membership tiers with different pricing:
+
+```sql
+CREATE TABLE public.membership_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE, -- 'regular', 'student', 'senior'
+ display_name TEXT NOT NULL, -- 'Regular Member', 'Student'
+ annual_dues DECIMAL(10,2) NOT NULL, -- 50.00, 25.00, etc.
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE, -- Default for new signups
+ is_active BOOLEAN DEFAULT TRUE, -- Can be assigned
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default membership types
+INSERT INTO membership_types (name, display_name, annual_dues, is_default) VALUES
+ ('regular', 'Regular Member', 50.00, true),
+ ('student', 'Student', 25.00, false),
+ ('senior', 'Senior (65+)', 35.00, false),
+ ('family', 'Family', 75.00, false),
+ ('honorary', 'Honorary Member', 0.00, false);
+```
+
+### 1.12 Complete Member Schema
+
+```sql
+CREATE TABLE public.members (
+ -- Identity
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ member_id TEXT UNIQUE NOT NULL, -- MUSA-0001 format (auto-generated)
+
+ -- Required Personal Info
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ phone TEXT NOT NULL,
+ date_of_birth DATE NOT NULL,
+ address TEXT NOT NULL,
+ nationality TEXT[] NOT NULL DEFAULT '{}',
+
+ -- Membership
+ role TEXT NOT NULL DEFAULT 'member'
+ CHECK (role IN ('member', 'board', 'admin')),
+ membership_status_id UUID REFERENCES public.membership_statuses(id),
+ membership_type_id UUID REFERENCES public.membership_types(id),
+ member_since DATE DEFAULT CURRENT_DATE,
+
+ -- Profile
+ avatar_url TEXT,
+
+ -- Admin
+ notes TEXT, -- Admin-only notes
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Auto-generate member_id trigger
+CREATE OR REPLACE FUNCTION generate_member_id()
+RETURNS TRIGGER AS $$
+DECLARE
+ next_num INTEGER;
+BEGIN
+ SELECT COALESCE(MAX(CAST(SUBSTRING(member_id FROM 6) AS INTEGER)), 0) + 1
+ INTO next_num
+ FROM public.members;
+
+ NEW.member_id := 'MUSA-' || LPAD(next_num::TEXT, 4, '0');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_member_id
+ BEFORE INSERT ON public.members
+ FOR EACH ROW
+ WHEN (NEW.member_id IS NULL)
+ EXECUTE FUNCTION generate_member_id();
+```
+
+---
+
+## 2. DUES/PAYMENTS SYSTEM (Detailed)
+
+### 2.1 Dues Cycle
+- **Due date calculation**: Payment date + 365 days
+- **Example**: Payment on Jan 15, 2026 → Due Jan 15, 2027
+- **No proration**: Full annual dues regardless of join date
+
+### 2.2 Payment Methods
+**Bank transfer only** (no online payments):
+- IBAN tracking
+- Reference number for matching
+- Manual recording by Board/Admin
+
+### 2.3 Payment Recording
+**Who can record payments:**
+- Board members
+- Admins
+
+**Standard payment data tracked:**
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `member_id` | UUID | Yes | Which member |
+| `amount` | Decimal | Yes | Payment amount (€) |
+| `payment_date` | Date | Yes | When payment was made |
+| `due_date` | Date | Yes | When this payment period ends (auto-calculated) |
+| `reference` | Text | No | Bank transfer reference |
+| `payment_method` | Text | Yes | Always 'bank_transfer' for now |
+| `recorded_by` | UUID | Yes | Board/Admin who recorded |
+| `notes` | Text | No | Optional notes |
+
+### 2.4 Dues Settings (Admin-Configurable)
+
+```sql
+CREATE TABLE public.dues_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ setting_key TEXT UNIQUE NOT NULL,
+ setting_value TEXT NOT NULL,
+ description TEXT,
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id)
+);
+
+-- Default settings
+INSERT INTO dues_settings (setting_key, setting_value, description) VALUES
+ ('reminder_days_before', '30,7', 'Days before due date to send reminders (comma-separated)'),
+ ('grace_period_days', '30', 'Days after due date before auto-inactive'),
+ ('overdue_reminder_interval', '14', 'Days between overdue reminder emails'),
+ ('payment_iban', 'MC58 1756 9000 0104 0050 1001 860', 'IBAN for dues payment'),
+ ('payment_account_holder', 'ASSOCIATION MONACO USA', 'Account holder name'),
+ ('payment_instructions', 'Please include your Member ID in the reference', 'Payment instructions');
+```
+
+### 2.5 Automatic Reminders
+
+**Reminder Schedule (configurable via settings):**
+1. **30 days before** due date: "Your dues are coming up"
+2. **7 days before** due date: "Reminder: dues due in 1 week"
+3. **On due date**: "Your dues are now due"
+4. **Every 14 days overdue**: "Your dues are overdue" (until grace period ends)
+
+**Email Content Includes:**
+- Member name
+- Amount due (from membership_type)
+- Due date
+- IBAN and account holder
+- Payment reference suggestion (Member ID)
+- Link to portal
+
+**Technical Implementation:**
+- Supabase Edge Function runs daily
+- Checks all members for reminder triggers
+- Logs sent emails in `email_logs` table
+- Respects settings for intervals
+
+### 2.6 Overdue Handling
+
+**Grace Period Flow:**
+```
+Due Date Passed
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ GRACE PERIOD (configurable, default 30 days) │
+│ - Status remains 'active' │
+│ - Overdue reminders sent │
+│ - Flagged in dashboard │
+└─────────────────────────────────────────┘
+ │
+ ▼ (grace period ends)
+┌─────────────────────────────────────────┐
+│ AUTO STATUS CHANGE │
+│ - Status → 'inactive' │
+│ - Final notification email │
+│ - Member loses active access │
+└─────────────────────────────────────────┘
+```
+
+**Supabase Edge Function for Auto-Update:**
+```typescript
+// Runs daily via cron
+async function updateOverdueMembers() {
+ const gracePeriodDays = await getSetting('grace_period_days');
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays);
+
+ // Find members past grace period
+ const { data: overdueMembers } = await supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('membership_status', 'active')
+ .lt('current_due_date', cutoffDate.toISOString());
+
+ // Update each to inactive
+ for (const member of overdueMembers) {
+ await supabase
+ .from('members')
+ .update({ membership_status_id: inactiveStatusId })
+ .eq('id', member.id);
+
+ // Send final notification
+ await sendEmail(member.email, 'membership_lapsed', { ... });
+ }
+}
+```
+
+### 2.7 Payment History (Member Visible)
+
+Members can see their complete payment history:
+
+**Display includes:**
+- Payment date
+- Amount paid
+- Due date (period covered)
+- Reference number
+- Payment method
+
+**Members CANNOT see:**
+- Who recorded the payment
+- Internal notes
+- Other members' payments
+
+### 2.8 Dues Dashboard (Board/Admin)
+
+**Overview Stats:**
+- Total members with current dues
+- Members with dues due soon (next 30 days)
+- Overdue members count
+- Total collected this year
+
+**Filterable Member List:**
+- Filter by: status (current, due soon, overdue, never paid)
+- Sort by: due date, days overdue, member name
+- Quick actions: Record payment, Send reminder
+
+**Individual Member View:**
+- Full payment history
+- Current dues status
+- Quick record payment form
+- Send manual reminder button
+
+### 2.9 Complete Dues Schema
+
+```sql
+-- Dues payments table
+CREATE TABLE public.dues_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ amount DECIMAL(10,2) NOT NULL,
+ currency TEXT DEFAULT 'EUR',
+ payment_date DATE NOT NULL,
+ due_date DATE NOT NULL, -- Calculated: payment_date + 1 year
+ payment_method TEXT DEFAULT 'bank_transfer',
+ reference TEXT, -- Bank transfer reference
+ notes TEXT, -- Internal notes
+
+ recorded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Trigger to auto-calculate due_date
+CREATE OR REPLACE FUNCTION calculate_due_date()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.due_date := NEW.payment_date + INTERVAL '1 year';
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_due_date
+ BEFORE INSERT ON public.dues_payments
+ FOR EACH ROW
+ WHEN (NEW.due_date IS NULL)
+ EXECUTE FUNCTION calculate_due_date();
+
+-- After payment: update member status to active
+CREATE OR REPLACE FUNCTION update_member_status_on_payment()
+RETURNS TRIGGER AS $$
+DECLARE
+ active_status_id UUID;
+BEGIN
+ -- Get active status ID
+ SELECT id INTO active_status_id
+ FROM public.membership_statuses
+ WHERE name = 'active';
+
+ -- Update member status
+ UPDATE public.members
+ SET membership_status_id = active_status_id,
+ updated_at = NOW()
+ WHERE id = NEW.member_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER activate_member_on_payment
+ AFTER INSERT ON public.dues_payments
+ FOR EACH ROW
+ EXECUTE FUNCTION update_member_status_on_payment();
+
+-- Computed view for dues status
+CREATE VIEW public.members_with_dues AS
+SELECT
+ m.*,
+ ms.name as status_name,
+ ms.display_name as status_display_name,
+ ms.color as status_color,
+ mt.display_name as membership_type_name,
+ mt.annual_dues,
+ dp.last_payment_date,
+ dp.current_due_date,
+ CASE
+ WHEN dp.current_due_date IS NULL THEN 'never_paid'
+ WHEN dp.current_due_date < CURRENT_DATE THEN 'overdue'
+ WHEN dp.current_due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
+ ELSE 'current'
+ END as dues_status,
+ CASE
+ WHEN dp.current_due_date < CURRENT_DATE
+ THEN (CURRENT_DATE - dp.current_due_date)::INTEGER
+ ELSE NULL
+ END as days_overdue,
+ CASE
+ WHEN dp.current_due_date >= CURRENT_DATE
+ THEN (dp.current_due_date - CURRENT_DATE)::INTEGER
+ ELSE NULL
+ END as days_until_due
+FROM public.members m
+LEFT JOIN public.membership_statuses ms ON m.membership_status_id = ms.id
+LEFT JOIN public.membership_types mt ON m.membership_type_id = mt.id
+LEFT JOIN LATERAL (
+ SELECT
+ payment_date as last_payment_date,
+ due_date as current_due_date
+ FROM public.dues_payments
+ WHERE member_id = m.id
+ ORDER BY due_date DESC
+ LIMIT 1
+) dp ON true;
+```
+
+### 2.10 Email Templates for Dues
+
+**Types:**
+1. `dues_reminder` - Upcoming dues reminder
+2. `dues_due_today` - Dues due today
+3. `dues_overdue` - Overdue reminder
+4. `dues_lapsed` - Membership lapsed (grace period ended)
+5. `dues_received` - Payment confirmation
+
+**Template Variables:**
+- `{{member_name}}` - Full name
+- `{{member_id}}` - MUSA-XXXX
+- `{{amount}}` - Due amount
+- `{{due_date}}` - Formatted date
+- `{{days_until_due}}` or `{{days_overdue}}`
+- `{{iban}}` - Payment IBAN
+- `{{account_holder}}` - Account name
+- `{{portal_link}}` - Link to portal
+
+---
+
+## 3. EVENTS SYSTEM (Detailed)
+
+### 3.1 Event Types (Admin-Configurable)
+
+```sql
+CREATE TABLE public.event_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#3b82f6', -- Tailwind blue-500
+ icon TEXT, -- Lucide icon name
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default event types
+INSERT INTO event_types (name, display_name, color, icon) VALUES
+ ('social', 'Social Event', '#10b981', 'party-popper'),
+ ('meeting', 'Meeting', '#6366f1', 'users'),
+ ('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake'),
+ ('workshop', 'Workshop', '#8b5cf6', 'graduation-cap'),
+ ('gala', 'Gala/Formal', '#ec4899', 'sparkles'),
+ ('other', 'Other', '#6b7280', 'calendar');
+```
+
+### 3.2 Event Visibility
+
+**Visibility Options:**
+| Level | Who Can See | Description |
+|-------|-------------|-------------|
+| `public` | Anyone | Visible on public events page (no login) |
+| `members` | All logged-in members | Default for most events |
+| `board` | Board + Admin only | Board meetings, internal events |
+| `admin` | Admin only | Administrative events |
+
+### 3.3 Event Pricing
+
+**Pricing Model:**
+- Each event can be free or paid
+- Paid events have **member price** and **non-member price**
+- Member pricing determined by `membership_type_id` (if tiered pricing enabled)
+- Non-members pay non-member price always
+
+**Pricing Fields:**
+```sql
+is_paid BOOLEAN DEFAULT FALSE,
+member_price DECIMAL(10,2) DEFAULT 0,
+non_member_price DECIMAL(10,2) DEFAULT 0,
+pricing_notes TEXT -- "Includes dinner and drinks"
+```
+
+### 3.4 Guest/+1 Handling
+
+**Per-Event Configuration:**
+- `max_guests_per_member` - 0, 1, 2, 3, or unlimited
+- Each RSVP tracks guest count and guest names
+- Guests count toward total capacity
+- Non-members can bring guests too (if enabled)
+
+### 3.5 Non-Member (Public) RSVP
+
+**Flow for public events:**
+```
+┌─────────────────┐ ┌──────────────────┐
+│ Public Events │────▶│ Event Detail │
+│ Page (no login) │ │ (public visible) │
+└─────────────────┘ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ RSVP Form │
+ │ (no account) │
+ │ - Name │
+ │ - Email │
+ │ - Phone │
+ │ - Guest count │
+ │ - Guest names │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ Payment Info │
+ │ (if paid event) │
+ │ - IBAN shown │
+ │ - Reference # │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ RSVP Confirmed │
+ │ (pending payment)│
+ │ Email sent │
+ └──────────────────┘
+```
+
+**Non-Member RSVP Table:**
+```sql
+CREATE TABLE public.event_rsvps_public (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+
+ -- Contact info (required)
+ full_name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ phone TEXT,
+
+ -- RSVP details
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+
+ -- Payment (for paid events)
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, email) -- One RSVP per email per event
+);
+```
+
+### 3.6 RSVP Status Options
+
+**For Members and Non-Members:**
+| Status | Description |
+|--------|-------------|
+| `confirmed` | Attending the event |
+| `declined` | Not attending |
+| `maybe` | Tentative/undecided |
+| `waitlist` | Event full, on waitlist |
+| `cancelled` | Cancelled RSVP |
+
+### 3.7 Capacity & Waitlist
+
+**Capacity Management:**
+- `max_attendees` - Total spots (null = unlimited)
+- Includes members + guests + non-members + their guests
+- When full, new RSVPs go to waitlist
+
+**Auto-Promote Waitlist:**
+```typescript
+// Trigger when RSVP is cancelled or declined
+async function promoteFromWaitlist(eventId: string) {
+ // Get event capacity
+ const event = await getEvent(eventId);
+ const currentCount = await getCurrentAttendeeCount(eventId);
+
+ if (event.max_attendees && currentCount >= event.max_attendees) {
+ return; // Still full
+ }
+
+ // Get oldest waitlist entry
+ const waitlisted = await supabase
+ .from('event_rsvps')
+ .select('*')
+ .eq('event_id', eventId)
+ .eq('status', 'waitlist')
+ .order('created_at', { ascending: true })
+ .limit(1)
+ .single();
+
+ if (waitlisted) {
+ // Promote to confirmed
+ await supabase
+ .from('event_rsvps')
+ .update({ status: 'confirmed' })
+ .eq('id', waitlisted.id);
+
+ // Send notification email
+ await sendEmail(waitlisted.member.email, 'waitlist_promoted', {
+ event_title: event.title,
+ event_date: event.start_datetime
+ });
+ }
+}
+```
+
+### 3.8 Attendance Tracking
+
+**Check-in System:**
+- Board/Admin can mark attendance after event
+- Checkbox per RSVP: attended yes/no
+- Track attendance rate per event
+- Member attendance history viewable
+
+```sql
+-- Add to RSVPs
+attended BOOLEAN DEFAULT FALSE,
+checked_in_at TIMESTAMPTZ,
+checked_in_by UUID REFERENCES public.members(id)
+```
+
+### 3.9 Calendar Views
+
+**Available Views:**
+1. **Month** - Traditional calendar grid
+2. **Week** - Weekly schedule view
+3. **Day** - Single day detailed view
+4. **List** - Upcoming events list
+
+**Using FullCalendar (SvelteKit compatible):**
+```typescript
+import Calendar from '@event-calendar/core';
+import TimeGrid from '@event-calendar/time-grid';
+import DayGrid from '@event-calendar/day-grid';
+import List from '@event-calendar/list';
+```
+
+### 3.10 Event Schema
+
+```sql
+CREATE TABLE public.events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Basic Info
+ title TEXT NOT NULL,
+ description TEXT,
+ event_type_id UUID REFERENCES public.event_types(id),
+
+ -- Date/Time
+ start_datetime TIMESTAMPTZ NOT NULL,
+ end_datetime TIMESTAMPTZ NOT NULL,
+ all_day BOOLEAN DEFAULT FALSE,
+ timezone TEXT DEFAULT 'Europe/Monaco',
+
+ -- Location
+ location TEXT,
+ location_url TEXT, -- Google Maps link, etc.
+
+ -- Capacity
+ max_attendees INTEGER, -- null = unlimited
+ max_guests_per_member INTEGER DEFAULT 1,
+
+ -- Pricing
+ is_paid BOOLEAN DEFAULT FALSE,
+ member_price DECIMAL(10,2) DEFAULT 0,
+ non_member_price DECIMAL(10,2) DEFAULT 0,
+ pricing_notes TEXT,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+
+ -- Status
+ status TEXT NOT NULL DEFAULT 'published'
+ CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
+
+ -- Media
+ cover_image_url TEXT, -- Event banner/cover image
+
+ -- Meta
+ created_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Member RSVPs
+CREATE TABLE public.event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+ notes TEXT,
+
+ -- Payment (for paid events)
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+ checked_in_at TIMESTAMPTZ,
+ checked_in_by UUID REFERENCES public.members(id),
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, member_id)
+);
+
+-- View for event with counts
+CREATE VIEW public.events_with_counts AS
+SELECT
+ e.*,
+ et.display_name as event_type_name,
+ et.color as event_type_color,
+ et.icon as event_type_icon,
+ COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0) as total_attendees,
+ COALESCE(member_rsvps.confirmed_count, 0) as member_count,
+ COALESCE(public_rsvps.confirmed_count, 0) as non_member_count,
+ COALESCE(member_rsvps.waitlist_count, 0) +
+ COALESCE(public_rsvps.waitlist_count, 0) as waitlist_count,
+ CASE
+ WHEN e.max_attendees IS NULL THEN FALSE
+ WHEN (COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0)) >= e.max_attendees THEN TRUE
+ ELSE FALSE
+ END as is_full
+FROM public.events e
+LEFT JOIN public.event_types et ON e.event_type_id = et.id
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps
+ WHERE event_id = e.id
+) member_rsvps ON true
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps_public
+ WHERE event_id = e.id
+) public_rsvps ON true;
+```
+
+### 3.11 Event Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| View public events | - | - | - |
+| View member events | ✓ | ✓ | ✓ |
+| View board events | - | ✓ | ✓ |
+| View admin events | - | - | ✓ |
+| RSVP to events | ✓ | ✓ | ✓ |
+| Create events | - | ✓ | ✓ |
+| Edit own events | - | ✓ | ✓ |
+| Edit any event | - | - | ✓ |
+| Delete events | - | - | ✓ |
+| Manage RSVPs | - | ✓ | ✓ |
+| Track attendance | - | ✓ | ✓ |
+
+### 3.12 Event Email Notifications
+
+**Email Types:**
+1. `event_created` - New event announcement (for public/member events)
+2. `event_reminder` - Reminder before event (configurable: 1 day, 1 hour)
+3. `event_updated` - Event details changed
+4. `event_cancelled` - Event cancelled
+5. `rsvp_confirmation` - RSVP received
+6. `waitlist_promoted` - Promoted from waitlist
+7. `event_payment_reminder` - Payment reminder for paid events
+
+**Template Variables:**
+- `{{event_title}}`, `{{event_date}}`, `{{event_time}}`
+- `{{event_location}}`, `{{event_description}}`
+- `{{member_name}}`, `{{guest_count}}`
+- `{{payment_amount}}`, `{{payment_iban}}`
+- `{{rsvp_status}}`, `{{portal_link}}`
+
+---
+
+## 4. AUTH & DASHBOARDS (Detailed)
+
+### 4.1 Authentication Method
+
+**Email/Password only** (no social login):
+- Standard email + password signup/login
+- Email verification required
+- Password reset via email
+- Remember me option (extended session)
+
+### 4.2 Login Page Design
+
+**Branded login with:**
+- Monaco USA logo
+- Association tagline
+- Login form (email, password, remember me)
+- Links: Forgot password, Sign up
+- Glass-morphism styling
+- Responsive (mobile-friendly)
+
+### 4.3 Auth Flow
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ SIGNUP FLOW │
+├──────────────────────────────────────────────────────────────┤
+│ /signup │
+│ ├── Full form (all required fields) │
+│ ├── Supabase Auth: signUp(email, password) │
+│ ├── Create member record (status: pending) │
+│ ├── Send verification email │
+│ └── Show "Check your email" message │
+│ │
+│ /auth/callback (email verification link) │
+│ ├── Verify email token │
+│ ├── Update email_verified = true │
+│ └── Redirect to /login with success message │
+└──────────────────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────────────────┐
+│ LOGIN FLOW │
+├──────────────────────────────────────────────────────────────┤
+│ /login │
+│ ├── Email + Password form │
+│ ├── Supabase Auth: signInWithPassword() │
+│ ├── Set session cookie (via Supabase SSR) │
+│ ├── Fetch member record │
+│ └── Redirect to /dashboard │
+└──────────────────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────────────────┐
+│ PASSWORD RESET │
+├──────────────────────────────────────────────────────────────┤
+│ /forgot-password │
+│ ├── Email input form │
+│ ├── Supabase Auth: resetPasswordForEmail() │
+│ └── Show "Check your email" message │
+│ │
+│ /auth/reset-password (from email link) │
+│ ├── New password form │
+│ ├── Supabase Auth: updateUser({ password }) │
+│ └── Redirect to /login with success │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### 4.4 Session Management
+
+**Supabase SSR Configuration:**
+```typescript
+// src/hooks.server.ts
+export const handle: Handle = async ({ event, resolve }) => {
+ event.locals.supabase = createServerClient(
+ PUBLIC_SUPABASE_URL,
+ PUBLIC_SUPABASE_ANON_KEY,
+ {
+ cookies: {
+ getAll: () => event.cookies.getAll(),
+ setAll: (cookies) => cookies.forEach(({ name, value, options }) =>
+ event.cookies.set(name, value, { ...options, path: '/' })
+ )
+ }
+ }
+ );
+
+ event.locals.safeGetSession = async () => {
+ const { data: { session } } = await event.locals.supabase.auth.getSession();
+ if (!session) return { session: null, user: null, member: null };
+
+ const { data: { user } } = await event.locals.supabase.auth.getUser();
+ if (!user) return { session: null, user: null, member: null };
+
+ // Fetch member record
+ const { data: member } = await event.locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', user.id)
+ .single();
+
+ return { session, user, member };
+ };
+
+ return resolve(event);
+};
+```
+
+### 4.5 Navigation Structure
+
+**Desktop: Collapsible Sidebar**
+```
+┌─────────────────────────────────────────────────────┐
+│ ┌─────┐ │
+│ │ │ Dashboard │
+│ │LOGO │ ───────────────────────────────────── │
+│ │ │ │
+│ └─────┘ [Sidebar Navigation] [Content] │
+│ │
+│ 📊 Dashboard │
+│ 👤 My Profile │
+│ 📅 Events │
+│ 💳 Payments │
+│ │
+│ ── Board ──────── (if board/admin) │
+│ 👥 Members │
+│ 📋 Dues Management │
+│ 📅 Event Management │
+│ │
+│ ── Admin ──────── (if admin) │
+│ ⚙️ Settings │
+│ 👥 User Management │
+│ 📄 Documents │
+│ │
+│ ───────────────── │
+│ 🚪 Logout │
+└─────────────────────────────────────────────────────┘
+```
+
+**Mobile: Bottom Navigation Bar**
+```
+┌─────────────────────────────────────┐
+│ │
+│ [Main Content] │
+│ │
+│ │
+├─────────────────────────────────────┤
+│ 🏠 📅 👤 ⚙️ ☰ │
+│ Home Events Profile Settings More │
+└─────────────────────────────────────┘
+```
+
+### 4.6 Unified Dashboard with Role Sections
+
+**Single `/dashboard` route with role-based sections:**
+
+```svelte
+
+
+
+
+
+
+
+
+
+{#if isBoard}
+
+
+
+
+{/if}
+
+
+{#if isAdmin}
+
+
+
+
+{/if}
+```
+
+### 4.7 Member Dashboard Section
+
+**Components:**
+1. **Welcome Card** - Greeting with name, membership status badge
+2. **Dues Status Card** - Current status, next due date, quick pay info
+3. **Upcoming Events Card** - Next 3-5 events with RSVP status
+4. **Profile Quick View** - Photo, basic info, edit link
+
+**Data Loaded:**
+```typescript
+// routes/(app)/dashboard/+page.server.ts
+export const load = async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ const upcomingEvents = await getUpcomingEventsForMember(member.id, 5);
+
+ return {
+ member,
+ upcomingEvents
+ };
+};
+```
+
+### 4.8 Board Dashboard Section
+
+**Additional Components (visible to board/admin):**
+1. **Member Stats Card** - Total, active, pending, inactive counts
+2. **Pending Members Card** - New signups awaiting approval/payment
+3. **Dues Overview Card** - Current, due soon, overdue breakdown
+4. **Recent RSVPs Card** - Latest event RSVPs
+
+**Board Stats:**
+```typescript
+interface BoardStats {
+ totalMembers: number;
+ activeMembers: number;
+ pendingMembers: number;
+ inactiveMembers: number;
+ duesSoon: number; // Due in next 30 days
+ duesOverdue: number; // Past due date
+ upcomingEvents: number;
+ pendingRsvps: number;
+}
+```
+
+### 4.9 Admin Dashboard Section
+
+**Additional Components (admin only):**
+1. **System Health Card** - Supabase status, email status
+2. **Recent Activity Card** - Latest logins, signups, payments
+3. **Quick Actions Card** - Add member, create event, send broadcast
+4. **Alerts Card** - Issues requiring attention
+
+**Admin Stats:**
+```typescript
+interface AdminStats extends BoardStats {
+ totalUsers: number; // Auth users
+ recentLogins: number; // Last 24 hours
+ failedLogins: number; // Last 24 hours
+ emailsSent: number; // This month
+ storageUsed: number; // MB
+}
+```
+
+### 4.10 Route Protection
+
+**Layout-level guards using SvelteKit:**
+
+```typescript
+// routes/(app)/+layout.server.ts
+import { redirect } from '@sveltejs/kit';
+
+export const load = async ({ locals }) => {
+ const { session, member } = await locals.safeGetSession();
+
+ if (!session) {
+ throw redirect(303, '/login');
+ }
+
+ return { member };
+};
+
+// routes/(app)/board/+layout.server.ts
+export const load = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ if (member.role !== 'board' && member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
+
+// routes/(app)/admin/+layout.server.ts
+export const load = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ if (member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
+```
+
+### 4.11 Responsive Breakpoints
+
+| Breakpoint | Width | Layout |
+|------------|-------|--------|
+| Mobile | < 640px | Bottom nav, stacked cards |
+| Tablet | 640-1024px | Collapsed sidebar rail, 2-column |
+| Desktop | > 1024px | Full sidebar, 3-column grid |
+
+### 4.12 Dashboard Glass-Morphism Design
+
+**Glass Card Base Style:**
+```css
+.glass-card {
+ background: rgba(255, 255, 255, 0.7);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.glass-card-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+```
+
+**Monaco Red Accent:**
+```css
+:root {
+ --monaco-red: #dc2626;
+ --monaco-red-light: #fee2e2;
+ --monaco-red-dark: #991b1b;
+}
+```
+
+---
+
+## 5. DOCUMENT STORAGE (Detailed)
+
+### 5.1 Document Categories (Admin-Configurable)
+
+```sql
+CREATE TABLE public.document_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ description TEXT,
+ icon TEXT, -- Lucide icon name
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default categories
+INSERT INTO document_categories (name, display_name, icon) VALUES
+ ('meeting_minutes', 'Meeting Minutes', 'file-text'),
+ ('governance', 'Governance & Bylaws', 'scale'),
+ ('legal', 'Legal Documents', 'briefcase'),
+ ('financial', 'Financial Reports', 'dollar-sign'),
+ ('member_resources', 'Member Resources', 'book-open'),
+ ('forms', 'Forms & Templates', 'clipboard'),
+ ('other', 'Other Documents', 'file');
+```
+
+### 5.2 Upload Permissions
+
+**Who can upload:**
+- Board members
+- Administrators
+
+**Members cannot upload** - they can only view documents shared with them.
+
+### 5.3 Document Visibility (Per-Document)
+
+**Visibility Options:**
+| Level | Who Can View |
+|-------|--------------|
+| `public` | Anyone (no login required) |
+| `members` | All logged-in members |
+| `board` | Board + Admin only |
+| `admin` | Admin only |
+
+**Custom permissions** can also specify specific member IDs for restricted access.
+
+### 5.4 Document Schema
+
+```sql
+CREATE TABLE public.documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Basic Info
+ title TEXT NOT NULL,
+ description TEXT,
+ category_id UUID REFERENCES public.document_categories(id),
+
+ -- File Info (Supabase Storage)
+ file_path TEXT NOT NULL, -- Storage path
+ file_name TEXT NOT NULL, -- Original filename
+ file_size INTEGER NOT NULL, -- Bytes
+ mime_type TEXT NOT NULL, -- 'application/pdf', etc.
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+
+ -- Optional: Specific member access (for restricted docs)
+ allowed_member_ids UUID[], -- If set, only these members can view
+
+ -- Version tracking
+ version INTEGER DEFAULT 1,
+ replaces_document_id UUID REFERENCES public.documents(id),
+
+ -- Metadata
+ uploaded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Document access log (for audit)
+CREATE TABLE public.document_access_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ document_id UUID NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
+ accessed_by UUID REFERENCES public.members(id), -- null if public access
+ access_type TEXT NOT NULL CHECK (access_type IN ('view', 'download')),
+ ip_address TEXT,
+ accessed_at TIMESTAMPTZ DEFAULT NOW()
+);
+```
+
+### 5.5 File Storage (Supabase Storage)
+
+**Bucket Configuration:**
+```typescript
+// Storage bucket: 'documents'
+// Path structure: documents/{category}/{year}/{filename}
+
+// Example paths:
+// documents/meeting_minutes/2026/board-meeting-2026-01-15.pdf
+// documents/governance/bylaws-v2.pdf
+// documents/financial/2025/annual-report-2025.pdf
+```
+
+**Upload Limits:**
+- Max file size: 50MB
+- Allowed types: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG
+
+### 5.6 Document UI Features
+
+**Document Library View:**
+- Filter by category
+- Filter by visibility level
+- Search by title/description
+- Sort by date, name, category
+- Grid or list view toggle
+
+**Document Card:**
+```
+┌────────────────────────────────────────┐
+│ 📄 [Category Icon] │
+│ │
+│ Board Meeting Minutes - January 2026 │
+│ Meeting minutes from the monthly... │
+│ │
+│ 📅 Jan 15, 2026 | 📎 PDF | 1.2 MB │
+│ │
+│ [View] [Download] 👁️ Members │
+└────────────────────────────────────────┘
+```
+
+**Upload Form (Board/Admin):**
+- Title (required)
+- Description (optional)
+- Category (required, dropdown)
+- Visibility (required)
+- Custom access (optional, member multi-select)
+- File upload (drag & drop)
+
+### 5.7 Document Permissions (RLS)
+
+```sql
+-- RLS Policies for documents
+ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
+
+-- Public documents viewable by anyone
+CREATE POLICY "Public documents are viewable"
+ ON public.documents FOR SELECT
+ USING (visibility = 'public');
+
+-- Member documents viewable by authenticated users
+CREATE POLICY "Member documents viewable by members"
+ ON public.documents FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ OR (allowed_member_ids IS NOT NULL AND auth.uid() = ANY(allowed_member_ids))
+ );
+
+-- Board/Admin can manage documents
+CREATE POLICY "Board can upload documents"
+ ON public.documents FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Uploader or admin can update documents"
+ ON public.documents FOR UPDATE
+ TO authenticated
+ USING (
+ uploaded_by = auth.uid()
+ OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+CREATE POLICY "Admin can delete documents"
+ ON public.documents FOR DELETE
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+```
+
+### 5.8 Version History
+
+**Document versioning:**
+- When replacing a document, create new record with `replaces_document_id`
+- Previous versions remain accessible (archived)
+- View version history for any document
+
+```sql
+-- Get version history for a document
+SELECT d.*, m.first_name, m.last_name
+FROM public.documents d
+JOIN public.members m ON d.uploaded_by = m.id
+WHERE d.id = :document_id
+ OR d.replaces_document_id = :document_id
+ OR d.id IN (
+ SELECT replaces_document_id FROM public.documents
+ WHERE id = :document_id
+ )
+ORDER BY d.version DESC;
+```
+
+### 5.9 Meeting Minutes Special Handling
+
+**For meeting minutes category:**
+- Date field (meeting date)
+- Attendees list (optional)
+- Agenda reference (optional)
+- Quick template for consistency
+
+```sql
+-- Optional meeting minutes metadata
+ALTER TABLE public.documents ADD COLUMN meeting_date DATE;
+ALTER TABLE public.documents ADD COLUMN meeting_attendees UUID[];
+```
+
+---
+
+## 6. ADMIN SETTINGS SYSTEM (Detailed)
+
+### 6.1 Settings Architecture Overview
+
+**Centralized configuration** for all customizable aspects of the portal, accessible only to Admins via `/admin/settings`.
+
+**Settings Categories:**
+1. **Organization** - Association branding and info
+2. **Membership** - Statuses, types, and pricing
+3. **Dues** - Payment settings and reminders
+4. **Events** - Event types and defaults
+5. **Documents** - Categories and storage
+6. **Directory** - Visibility controls
+7. **Email** - SMTP and template settings
+8. **System** - Technical settings
+
+### 6.2 Settings Storage (Unified Table)
+
+```sql
+-- Flexible key-value settings with JSON support
+CREATE TABLE public.app_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category TEXT NOT NULL, -- 'organization', 'dues', 'email', etc.
+ setting_key TEXT NOT NULL, -- 'payment_iban', 'reminder_days', etc.
+ setting_value JSONB NOT NULL, -- Supports strings, numbers, arrays, objects
+ setting_type TEXT NOT NULL DEFAULT 'text' -- 'text', 'number', 'boolean', 'json', 'array'
+ CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')),
+ display_name TEXT NOT NULL, -- Human-readable label
+ description TEXT, -- Help text for admins
+ is_public BOOLEAN DEFAULT FALSE, -- If true, accessible without auth
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id),
+
+ UNIQUE(category, setting_key)
+);
+
+-- Audit log for settings changes
+CREATE TABLE public.settings_audit_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ setting_id UUID NOT NULL REFERENCES public.app_settings(id),
+ old_value JSONB,
+ new_value JSONB NOT NULL,
+ changed_by UUID NOT NULL REFERENCES public.members(id),
+ changed_at TIMESTAMPTZ DEFAULT NOW(),
+ change_reason TEXT
+);
+
+-- RLS: Only admins can read/write settings
+ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Only admins can manage settings"
+ ON public.app_settings FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ OR is_public = TRUE
+ );
+```
+
+### 6.3 Default Settings (Seeded on First Run)
+
+```sql
+-- Organization Settings
+INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
+ ('organization', 'association_name', '"Monaco USA"', 'text', 'Association Name', 'Official name of the association', true),
+ ('organization', 'tagline', '"Americans in Monaco"', 'text', 'Tagline', 'Association tagline shown on login', true),
+ ('organization', 'contact_email', '"contact@monacousa.org"', 'text', 'Contact Email', 'Public contact email address', true),
+ ('organization', 'address', '"Monaco"', 'text', 'Address', 'Association physical address', true),
+ ('organization', 'logo_url', '"/logo.png"', 'text', 'Logo URL', 'Path to association logo', true),
+ ('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true),
+
+-- Dues Settings
+ ('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues payment', false),
+ ('dues', 'payment_account_holder', '"ASSOCIATION MONACO USA"', 'text', 'Account Holder', 'Bank account holder name', false),
+ ('dues', 'payment_bank_name', '"Credit Foncier de Monaco"', 'text', 'Bank Name', 'Name of the bank', false),
+ ('dues', 'payment_instructions', '"Please include your Member ID (MUSA-XXXX) in the reference"', 'text', 'Payment Instructions', 'Instructions shown to members', false),
+ ('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due date to send reminders', false),
+ ('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due date before auto-inactive', false),
+ ('dues', 'overdue_reminder_interval', '14', 'number', 'Overdue Reminder Interval', 'Days between overdue reminder emails', false),
+ ('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Automatically set members inactive after grace period', false),
+
+-- Event Settings
+ ('events', 'default_max_guests', '2', 'number', 'Default Max Guests', 'Default maximum guests per RSVP', false),
+ ('events', 'reminder_hours_before', '[24, 1]', 'array', 'Event Reminder Hours', 'Hours before event to send reminders', false),
+ ('events', 'allow_public_rsvp', 'true', 'boolean', 'Allow Public RSVP', 'Allow non-members to RSVP to public events', false),
+ ('events', 'auto_close_rsvp_hours', '0', 'number', 'Auto Close RSVP', 'Hours before event to close RSVP (0 = never)', false),
+
+-- Directory Settings
+ ('directory', 'member_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "member_since"]', 'array', 'Member Visible Fields', 'Fields visible to regular members', false),
+ ('directory', 'board_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "email", "phone", "address", "date_of_birth", "member_since", "membership_status"]', 'array', 'Board Visible Fields', 'Fields visible to board members', false),
+ ('directory', 'show_membership_status', 'false', 'boolean', 'Show Status to Members', 'Show membership status in directory for regular members', false),
+
+-- System Settings
+ ('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put the portal in maintenance mode', false),
+ ('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false),
+ ('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false),
+ ('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', false),
+ ('system', 'allowed_file_types', '["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "jpg", "jpeg", "png", "webp"]', 'array', 'Allowed File Types', 'Allowed file extensions for uploads', false);
+```
+
+### 6.4 Settings UI Layout
+
+**Navigation Tabs:**
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ ⚙️ Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ [Organization] [Membership] [Dues] [Events] [Documents] │
+│ [Directory] [Email] [System] │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐ │
+│ │ Organization Settings │ │
+│ │ ───────────────────────────────────────────────────────── │ │
+│ │ │ │
+│ │ Association Name │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Monaco USA │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ │ Official name of the association │ │
+│ │ │ │
+│ │ Tagline │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Americans in Monaco │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ │ Association tagline shown on login │ │
+│ │ │ │
+│ │ Primary Color │ │
+│ │ ┌────────┐ ┌──────────────────────────────────────────┐ │ │
+│ │ │ 🎨 │ │ #dc2626 │ │ │
+│ │ └────────┘ └──────────────────────────────────────────┘ │ │
+│ │ │ │
+│ │ [Save Changes] │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.5 Membership Settings Tab
+
+**Manages configurable membership statuses and types:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Membership Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ MEMBERSHIP STATUSES │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ┌───────────┬─────────────┬──────────┬────────────┬──────────┐ │
+│ │ Name │ Display │ Color │ Is Default │ Actions │ │
+│ ├───────────┼─────────────┼──────────┼────────────┼──────────┤ │
+│ │ pending │ Pending │ 🟡 Yellow│ ✓ │ ✏️ 🗑️ │ │
+│ │ active │ Active │ 🟢 Green │ │ ✏️ 🗑️ │ │
+│ │ inactive │ Inactive │ ⚪ Gray │ │ ✏️ 🗑️ │ │
+│ │ expired │ Expired │ 🔴 Red │ │ ✏️ 🗑️ │ │
+│ └───────────┴─────────────┴──────────┴────────────┴──────────┘ │
+│ │
+│ [+ Add Status] │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ MEMBERSHIP TYPES │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ┌───────────┬───────────────┬──────────┬────────────┬────────┐ │
+│ │ Name │ Display │ Annual € │ Is Default │Actions │ │
+│ ├───────────┼───────────────┼──────────┼────────────┼────────┤ │
+│ │ regular │ Regular │ €50.00 │ ✓ │ ✏️ 🗑️ │ │
+│ │ student │ Student │ €25.00 │ │ ✏️ 🗑️ │ │
+│ │ senior │ Senior (65+) │ €35.00 │ │ ✏️ 🗑️ │ │
+│ │ family │ Family │ €75.00 │ │ ✏️ 🗑️ │ │
+│ │ honorary │ Honorary │ €0.00 │ │ ✏️ 🗑️ │ │
+│ └───────────┴───────────────┴──────────┴────────────┴────────┘ │
+│ │
+│ [+ Add Membership Type] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.6 Event Types Settings
+
+**Admin can manage event types with colors and icons:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Event Types │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────┬───────────────┬────────────┬────────┬────────┐ │
+│ │ Name │ Display │ Color │ Icon │Actions │ │
+│ ├─────────────┼───────────────┼────────────┼────────┼────────┤ │
+│ │ social │ Social Event │ 🟢 #10b981 │ 🎉 │ ✏️ 🗑️ │ │
+│ │ meeting │ Meeting │ 🔵 #6366f1 │ 👥 │ ✏️ 🗑️ │ │
+│ │ fundraiser │ Fundraiser │ 🟠 #f59e0b │ 💝 │ ✏️ 🗑️ │ │
+│ │ workshop │ Workshop │ 🟣 #8b5cf6 │ 🎓 │ ✏️ 🗑️ │ │
+│ │ gala │ Gala/Formal │ 🌸 #ec4899 │ ✨ │ ✏️ 🗑️ │ │
+│ │ other │ Other │ ⚫ #6b7280 │ 📅 │ ✏️ 🗑️ │ │
+│ └─────────────┴───────────────┴────────────┴────────┴────────┘ │
+│ │
+│ [+ Add Event Type] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.7 Document Categories Settings
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Document Categories │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────┬─────────────────────┬────────┬────────────┐ │
+│ │ Name │ Display │ Icon │ Actions │ │
+│ ├─────────────────┼─────────────────────┼────────┼────────────┤ │
+│ │ meeting_minutes │ Meeting Minutes │ 📄 │ ✏️ 🗑️ │ │
+│ │ governance │ Governance & Bylaws │ ⚖️ │ ✏️ 🗑️ │ │
+│ │ legal │ Legal Documents │ 💼 │ ✏️ 🗑️ │ │
+│ │ financial │ Financial Reports │ 💰 │ ✏️ 🗑️ │ │
+│ │ member_resources│ Member Resources │ 📚 │ ✏️ 🗑️ │ │
+│ │ forms │ Forms & Templates │ 📋 │ ✏️ 🗑️ │ │
+│ │ other │ Other Documents │ 📁 │ ✏️ 🗑️ │ │
+│ └─────────────────┴─────────────────────┴────────┴────────────┘ │
+│ │
+│ [+ Add Category] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.8 Directory Visibility Settings
+
+**Admin controls what fields are visible to different roles:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Directory Visibility │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Configure which member fields are visible in the directory. │
+│ Admin always sees all fields. │
+│ │
+│ ┌─────────────────┬──────────────────┬──────────────────┐ │
+│ │ Field │ Visible to │ Visible to │ │
+│ │ │ Members │ Board │ │
+│ ├─────────────────┼──────────────────┼──────────────────┤ │
+│ │ First Name │ ☑️ Always shown │ ☑️ Always shown │ │
+│ │ Last Name │ ☑️ Always shown │ ☑️ Always shown │ │
+│ │ Profile Photo │ ☑️ │ ☑️ │ │
+│ │ Nationality │ ☑️ │ ☑️ │ │
+│ │ Email │ ☐ │ ☑️ │ │
+│ │ Phone │ ☐ │ ☑️ │ │
+│ │ Address │ ☐ │ ☑️ │ │
+│ │ Date of Birth │ ☐ │ ☑️ │ │
+│ │ Member Since │ ☑️ │ ☑️ │ │
+│ │ Status │ ☐ │ ☑️ │ │
+│ │ Membership Type │ ☐ │ ☑️ │ │
+│ └─────────────────┴──────────────────┴──────────────────┘ │
+│ │
+│ [Save Visibility Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.9 System Settings Tab
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ System Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ MAINTENANCE │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ☐ Enable Maintenance Mode │
+│ │
+│ Maintenance Message: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ The portal is currently undergoing maintenance. │ │
+│ │ Please check back soon. │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ SECURITY │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Session Timeout (hours): │
+│ ┌────────────┐ │
+│ │ 168 │ (7 days) │
+│ └────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ FILE UPLOADS │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Max Upload Size (MB): │
+│ ┌────────────┐ │
+│ │ 50 │ │
+│ └────────────┘ │
+│ │
+│ Allowed File Types: │
+│ [PDF] [DOC] [DOCX] [XLS] [XLSX] [PPT] [PPTX] │
+│ [TXT] [JPG] [PNG] [WEBP] [+ Add Type] │
+│ │
+│ [Save System Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.10 Settings Access Pattern
+
+```typescript
+// src/lib/server/settings.ts
+
+// Get a single setting with type safety
+export async function getSetting(
+ supabase: SupabaseClient,
+ category: string,
+ key: string,
+ defaultValue: T
+): Promise {
+ const { data } = await supabase
+ .from('app_settings')
+ .select('setting_value')
+ .eq('category', category)
+ .eq('setting_key', key)
+ .single();
+
+ return data?.setting_value ?? defaultValue;
+}
+
+// Get all settings for a category
+export async function getCategorySettings(
+ supabase: SupabaseClient,
+ category: string
+): Promise> {
+ const { data } = await supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', category);
+
+ return Object.fromEntries(
+ (data ?? []).map(s => [s.setting_key, s.setting_value])
+ );
+}
+
+// Update a setting (admin only)
+export async function updateSetting(
+ supabase: SupabaseClient,
+ category: string,
+ key: string,
+ value: any,
+ userId: string
+): Promise {
+ await supabase
+ .from('app_settings')
+ .update({
+ setting_value: value,
+ updated_at: new Date().toISOString(),
+ updated_by: userId
+ })
+ .eq('category', category)
+ .eq('setting_key', key);
+}
+```
+
+### 6.11 Settings Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| View public settings | ✓ | ✓ | ✓ |
+| View all settings | - | - | ✓ |
+| Edit settings | - | - | ✓ |
+| Manage statuses | - | - | ✓ |
+| Manage membership types | - | - | ✓ |
+| Manage event types | - | - | ✓ |
+| Manage document categories | - | - | ✓ |
+| View settings audit log | - | - | ✓ |
+
+---
+
+## 7. EMAIL SYSTEM (Detailed)
+
+### 7.1 Email Architecture
+
+**Provider**: Supabase Edge Functions + external SMTP (Resend, SendGrid, or Mailgun)
+
+**Why external SMTP:**
+- Supabase built-in email is limited to auth emails only
+- External SMTP provides better deliverability, tracking, and templates
+- Resend recommended for simplicity and modern API
+
+### 7.2 Email Provider Configuration
+
+```sql
+-- Email settings (stored in app_settings)
+INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description) VALUES
+ ('email', 'provider', '"resend"', 'text', 'Email Provider', 'Email service provider (resend, sendgrid, mailgun)'),
+ ('email', 'api_key', '""', 'text', 'API Key', 'Email provider API key (stored securely)'),
+ ('email', 'from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address'),
+ ('email', 'from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender name'),
+ ('email', 'reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address'),
+ ('email', 'enable_tracking', 'true', 'boolean', 'Enable Tracking', 'Track email opens and clicks'),
+ ('email', 'batch_size', '50', 'number', 'Batch Size', 'Max emails per batch send'),
+ ('email', 'rate_limit_per_hour', '100', 'number', 'Rate Limit', 'Maximum emails per hour');
+```
+
+### 7.3 Email Types & Triggers
+
+| Email Type | Trigger | Recipients | Automated |
+|------------|---------|------------|-----------|
+| `welcome` | New signup verified | New member | Yes |
+| `email_verification` | Signup | New member | Yes (Supabase) |
+| `password_reset` | Password reset request | Member | Yes (Supabase) |
+| `dues_reminder` | X days before due | Member | Yes (cron) |
+| `dues_due_today` | Due date | Member | Yes (cron) |
+| `dues_overdue` | Every X days overdue | Member | Yes (cron) |
+| `dues_lapsed` | Grace period ends | Member | Yes (cron) |
+| `dues_received` | Payment recorded | Member | Yes |
+| `event_created` | New event published | All/visibility | Optional |
+| `event_reminder` | X hours before event | RSVP'd members | Yes (cron) |
+| `event_updated` | Event details changed | RSVP'd members | Yes |
+| `event_cancelled` | Event cancelled | RSVP'd members | Yes |
+| `rsvp_confirmation` | RSVP submitted | Member | Yes |
+| `waitlist_promoted` | Spot opens up | Waitlisted member | Yes |
+| `member_invite` | Admin invites member | Invitee | Manual |
+| `broadcast` | Admin sends message | Selected members | Manual |
+
+### 7.4 Email Templates Schema
+
+```sql
+CREATE TABLE public.email_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Template identification
+ template_key TEXT UNIQUE NOT NULL, -- 'dues_reminder', 'welcome', etc.
+ template_name TEXT NOT NULL, -- 'Dues Reminder Email'
+ category TEXT NOT NULL, -- 'dues', 'events', 'system'
+
+ -- Template content
+ subject TEXT NOT NULL, -- Subject line with {{variables}}
+ body_html TEXT NOT NULL, -- HTML body with {{variables}}
+ body_text TEXT, -- Plain text fallback
+
+ -- Settings
+ is_active BOOLEAN DEFAULT TRUE,
+ is_system BOOLEAN DEFAULT FALSE, -- System templates can't be deleted
+
+ -- Metadata
+ variables_schema JSONB, -- Available variables documentation
+ preview_data JSONB, -- Sample data for preview
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id)
+);
+
+-- Default email templates
+INSERT INTO email_templates (template_key, template_name, category, subject, body_html, is_system, variables_schema) VALUES
+
+-- Welcome Email
+('welcome', 'Welcome Email', 'system',
+ 'Welcome to Monaco USA, {{member_name}}!',
+ '
+
+
+
+
+
+
Welcome to Monaco USA!
+
Dear {{member_name}},
+
Thank you for joining Monaco USA! Your Member ID is {{member_id}} .
+
To complete your membership, please pay your annual dues of €{{dues_amount}} .
+
Payment Details:
+
+ Bank: {{bank_name}}
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
Access Your Portal
+
Best regards, Monaco USA Team
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","bank_name":"string","iban":"string","account_holder":"string","portal_link":"string","logo_url":"string"}'
+),
+
+-- Dues Reminder
+('dues_reminder', 'Dues Reminder', 'dues',
+ 'Your Monaco USA dues are due in {{days_until_due}} days',
+ '
+
+
+
+
Dues Reminder
+
Dear {{member_name}},
+
This is a friendly reminder that your Monaco USA membership dues of €{{dues_amount}} are due on {{due_date}} ({{days_until_due}} days from now).
+
Payment Details:
+
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
View Payment Details
+
Thank you for being a valued member!
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","due_date":"date","days_until_due":"number","iban":"string","account_holder":"string","portal_link":"string"}'
+),
+
+-- Dues Overdue
+('dues_overdue', 'Dues Overdue Notice', 'dues',
+ 'OVERDUE: Your Monaco USA dues are {{days_overdue}} days past due',
+ '
+
+
+
+
Payment Overdue
+
Dear {{member_name}},
+
Your Monaco USA membership dues of €{{dues_amount}} are now {{days_overdue}} days overdue .
+
Please make your payment as soon as possible to maintain your membership benefits.
+ {{#if grace_period_remaining}}
+
Note: You have {{grace_period_remaining}} days remaining in your grace period before your membership is set to inactive.
+ {{/if}}
+
Payment Details:
+
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
Pay Now
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","days_overdue":"number","grace_period_remaining":"number","iban":"string","account_holder":"string","portal_link":"string"}'
+),
+
+-- Dues Received
+('dues_received', 'Payment Confirmation', 'dues',
+ 'Thank you! Your Monaco USA dues payment has been received',
+ '
+
+
+
+
Payment Received!
+
Dear {{member_name}},
+
Thank you! We have received your membership dues payment.
+
Payment Details:
+
+ Amount: €{{amount_paid}}
+ Payment Date: {{payment_date}}
+ Next Due Date: {{next_due_date}}
+ Reference: {{payment_reference}}
+
+
Your membership is now active until {{next_due_date}}.
+
View Payment History
+
+
+',
+ TRUE,
+ '{"member_name":"string","amount_paid":"number","payment_date":"date","next_due_date":"date","payment_reference":"string","portal_link":"string"}'
+),
+
+-- Event RSVP Confirmation
+('rsvp_confirmation', 'RSVP Confirmation', 'events',
+ 'You''re registered: {{event_title}}',
+ '
+
+
+
+
You''re Registered!
+
Dear {{member_name}},
+
Your RSVP for {{event_title}} has been confirmed.
+
Event Details:
+
+ Date: {{event_date}}
+ Time: {{event_time}}
+ Location: {{event_location}}
+ {{#if guest_count}}Additional Guests: {{guest_count}} {{/if}}
+
+ {{#if is_paid}}
+
Payment Required:
+
Total: €{{total_amount}}
+
+ IBAN: {{iban}}
+ Reference: {{payment_reference}}
+
+ {{/if}}
+
View Event
+
+
+',
+ TRUE,
+ '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","guest_count":"number","is_paid":"boolean","total_amount":"number","iban":"string","payment_reference":"string","event_id":"string","portal_link":"string"}'
+),
+
+-- Event Reminder
+('event_reminder', 'Event Reminder', 'events',
+ 'Reminder: {{event_title}} is {{time_until_event}}',
+ '
+
+
+
+
Event Reminder
+
Dear {{member_name}},
+
This is a reminder that {{event_title}} is {{time_until_event}}.
+
Event Details:
+
+ Date: {{event_date}}
+ Time: {{event_time}}
+ Location: {{event_location}}
+
+ {{#if event_description}}
+
{{event_description}}
+ {{/if}}
+
We look forward to seeing you there!
+
+
+',
+ TRUE,
+ '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","event_description":"string","time_until_event":"string"}'
+);
+```
+
+### 7.5 Email Logging Schema
+
+```sql
+-- Enhanced email logs with tracking
+CREATE TABLE public.email_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Recipients
+ recipient_id UUID REFERENCES public.members(id),
+ recipient_email TEXT NOT NULL,
+ recipient_name TEXT,
+
+ -- Email details
+ template_key TEXT REFERENCES public.email_templates(template_key),
+ subject TEXT NOT NULL,
+ email_type TEXT NOT NULL,
+
+ -- Status tracking
+ status TEXT NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')),
+
+ -- Provider data
+ provider TEXT, -- 'resend', 'sendgrid', etc.
+ provider_message_id TEXT, -- External message ID for tracking
+
+ -- Engagement tracking
+ opened_at TIMESTAMPTZ,
+ clicked_at TIMESTAMPTZ,
+
+ -- Error handling
+ error_message TEXT,
+ retry_count INTEGER DEFAULT 0,
+
+ -- Metadata
+ template_variables JSONB, -- Variables used in template
+ sent_by UUID REFERENCES public.members(id), -- For manual sends
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sent_at TIMESTAMPTZ,
+ delivered_at TIMESTAMPTZ
+);
+
+-- Index for common queries
+CREATE INDEX idx_email_logs_recipient ON public.email_logs(recipient_id);
+CREATE INDEX idx_email_logs_status ON public.email_logs(status);
+CREATE INDEX idx_email_logs_type ON public.email_logs(email_type);
+CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC);
+```
+
+### 7.6 Automated Email Scheduler (Supabase Edge Function)
+
+```typescript
+// supabase/functions/email-scheduler/index.ts
+
+import { createClient } from '@supabase/supabase-js';
+import { Resend } from 'resend';
+
+// Runs daily via pg_cron
+Deno.serve(async (req) => {
+ const supabase = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+ );
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // 1. Get settings
+ const settings = await getSettings(supabase);
+ const reminderDays = settings.reminder_days_before as number[];
+ const gracePeriod = settings.grace_period_days as number;
+
+ // 2. Find members needing reminders
+ const { data: membersWithDues } = await supabase
+ .from('members_with_dues')
+ .select('*')
+ .in('dues_status', ['current', 'due_soon', 'overdue']);
+
+ for (const member of membersWithDues || []) {
+ const dueDate = new Date(member.current_due_date);
+ const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ const daysOverdue = member.days_overdue || 0;
+
+ // Check if we need to send a reminder
+ if (daysUntil > 0 && reminderDays.includes(daysUntil)) {
+ // Send upcoming reminder
+ await sendEmail(supabase, 'dues_reminder', member, {
+ days_until_due: daysUntil
+ });
+ } else if (daysUntil === 0) {
+ // Due today
+ await sendEmail(supabase, 'dues_due_today', member, {});
+ } else if (daysOverdue > 0 && daysOverdue <= gracePeriod) {
+ // Overdue but in grace period
+ if (daysOverdue % settings.overdue_reminder_interval === 0) {
+ await sendEmail(supabase, 'dues_overdue', member, {
+ days_overdue: daysOverdue,
+ grace_period_remaining: gracePeriod - daysOverdue
+ });
+ }
+ } else if (daysOverdue === gracePeriod + 1) {
+ // Grace period just ended
+ await sendEmail(supabase, 'dues_lapsed', member, {});
+ }
+ }
+
+ // 3. Send event reminders
+ await sendEventReminders(supabase, settings);
+
+ return new Response(JSON.stringify({ success: true }), {
+ headers: { 'Content-Type': 'application/json' }
+ });
+});
+
+async function sendEmail(
+ supabase: any,
+ templateKey: string,
+ member: any,
+ extraVariables: Record
+) {
+ // Get template
+ const { data: template } = await supabase
+ .from('email_templates')
+ .select('*')
+ .eq('template_key', templateKey)
+ .eq('is_active', true)
+ .single();
+
+ if (!template) return;
+
+ // Build variables
+ const variables = {
+ member_name: `${member.first_name} ${member.last_name}`,
+ member_id: member.member_id,
+ dues_amount: member.annual_dues || 50,
+ due_date: member.current_due_date,
+ portal_link: Deno.env.get('PORTAL_URL'),
+ ...extraVariables
+ };
+
+ // Add payment settings
+ const settings = await getSettings(supabase);
+ variables.iban = settings.payment_iban;
+ variables.account_holder = settings.payment_account_holder;
+ variables.bank_name = settings.payment_bank_name;
+
+ // Render template
+ const subject = renderTemplate(template.subject, variables);
+ const html = renderTemplate(template.body_html, variables);
+
+ // Send via provider
+ const resend = new Resend(Deno.env.get('RESEND_API_KEY'));
+ const result = await resend.emails.send({
+ from: `${settings.from_name} <${settings.from_address}>`,
+ to: member.email,
+ subject,
+ html
+ });
+
+ // Log email
+ await supabase.from('email_logs').insert({
+ recipient_id: member.id,
+ recipient_email: member.email,
+ recipient_name: variables.member_name,
+ template_key: templateKey,
+ subject,
+ email_type: templateKey,
+ status: result.error ? 'failed' : 'sent',
+ provider: 'resend',
+ provider_message_id: result.data?.id,
+ error_message: result.error?.message,
+ template_variables: variables,
+ sent_at: new Date().toISOString()
+ });
+}
+```
+
+### 7.7 Email Settings UI (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ PROVIDER CONFIGURATION │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Email Provider: │
+│ ┌────────────────────────────────────────┐ │
+│ │ Resend ▼ │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ API Key: │
+│ ┌────────────────────────────────────────┐ │
+│ │ re_••••••••••••••• │ [Test Connection] │
+│ └────────────────────────────────────────┘ │
+│ │
+│ From Address: │
+│ ┌────────────────────────────────────────┐ │
+│ │ noreply@monacousa.org │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ From Name: │
+│ ┌────────────────────────────────────────┐ │
+│ │ Monaco USA │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ Reply-To: │
+│ ┌────────────────────────────────────────┐ │
+│ │ contact@monacousa.org │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ TRACKING │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ☑️ Enable open tracking │
+│ ☑️ Enable click tracking │
+│ │
+│ [Save Email Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.8 Email Templates Editor (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Templates │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────┬────────────────┬─────────┬──────────────┐ │
+│ │ Template │ Category │ Active │ Actions │ │
+│ ├───────────────────┼────────────────┼─────────┼──────────────┤ │
+│ │ Welcome Email │ System │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Reminder │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Due Today │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Overdue │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Lapsed │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Received │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ RSVP Confirmation │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Event Reminder │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Event Cancelled │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Waitlist Promoted │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ └───────────────────┴────────────────┴─────────┴──────────────┘ │
+│ │
+│ Legend: ✏️ Edit | 👁️ Preview | 📧 Send Test │
+│ │
+│ [+ Create Custom Template] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.9 Template Editor View
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Edit Template: Dues Reminder │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Template Name: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Dues Reminder │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ Subject Line: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Your Monaco USA dues are due in {{days_until_due}} days │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────────────┬───────────────────────────────────┐ │
+│ │ HTML Editor │ Preview │ │
+│ ├──────────────────────┼───────────────────────────────────┤ │
+│ │ Dues Reminder Dear {{member_nam │ ┌─────────────────────────────┐ │ │
+│ │ e}},
│ │ Dues Reminder │ │ │
+│ │ This is a friendl │ │ │ │ │
+│ │ y reminder that your │ │ Dear John Doe, │ │ │
+│ │ Monaco USA membershi │ │ │ │ │
+│ │ p dues...
│ │ This is a friendly │ │ │
+│ │ ... │ │ reminder that your Monaco │ │ │
+│ │ │ │ USA membership dues of │ │ │
+│ │ │ │ €50.00 are due on... │ │ │
+│ │ │ └─────────────────────────────┘ │ │
+│ └──────────────────────┴───────────────────────────────────┘ │
+│ │
+│ AVAILABLE VARIABLES: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ {{member_name}} {{member_id}} {{dues_amount}} {{due_date}}│ │
+│ │ {{days_until_due}} {{iban}} {{account_holder}} │ │
+│ │ {{portal_link}} │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ [Cancel] [Send Test Email] [Save Changes] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.10 Email Logs View (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Logs │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Filter: [All Types ▼] [All Status ▼] [Last 30 days ▼] [Search] │
+│ │
+│ ┌────────┬────────────────┬──────────────┬────────┬──────────┐ │
+│ │ Date │ Recipient │ Subject │ Type │ Status │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 9 │ john@email.com │ Your Monaco │ dues_ │ 📬 Opened │ │
+│ │ 14:30 │ John Doe │ USA dues... │ remind │ │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 9 │ jane@email.com │ You're regis │ rsvp_ │ ✅ Sent │ │
+│ │ 10:15 │ Jane Smith │ tered: Gala │ conf │ │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 8 │ bob@email.com │ OVERDUE: You │ dues_ │ 🔴 Bounce │ │
+│ │ 09:00 │ Bob Wilson │ r Monaco... │ overdu │ │ │
+│ └────────┴────────────────┴──────────────┴────────┴──────────┘ │
+│ │
+│ Status Legend: │
+│ ✅ Sent | 📬 Opened | 🔗 Clicked | 🔴 Bounced | ❌ Failed │
+│ │
+│ Stats: 156 sent this month | 78% open rate | 2 bounces │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.11 Manual Broadcast Feature (Admin)
+
+**Admin can send broadcast emails to selected members:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Send Broadcast Email │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Recipients: │
+│ ○ All active members (45) │
+│ ○ All members (52) │
+│ ○ Board members only (5) │
+│ ● Select specific members │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ 🔍 Search members... │ │
+│ ├──────────────────────────────────────────────────────────┤ │
+│ │ ☑️ John Doe (john@email.com) │ │
+│ │ ☑️ Jane Smith (jane@email.com) │ │
+│ │ ☐ Bob Wilson (bob@email.com) │ │
+│ │ ... │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ Selected: 2 members │
+│ │
+│ Subject: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Important Update from Monaco USA │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ Message: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ [Rich text editor with formatting options] │ │
+│ │ │ │
+│ │ Dear {{member_name}}, │ │
+│ │ │ │
+│ │ We wanted to inform you about... │ │
+│ │ │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ [Preview] [Send Test to Myself] [Send to 2 Recipients] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.12 Email Cron Jobs (pg_cron in Supabase)
+
+```sql
+-- Enable pg_cron extension
+CREATE EXTENSION IF NOT EXISTS pg_cron;
+
+-- Schedule daily email checks (runs at 9 AM Monaco time)
+SELECT cron.schedule(
+ 'daily-email-scheduler',
+ '0 9 * * *', -- Every day at 9:00 AM
+ $$
+ SELECT net.http_post(
+ url := 'https://your-project.supabase.co/functions/v1/email-scheduler',
+ headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
+ body := '{}'::jsonb
+ );
+ $$
+);
+
+-- Schedule event reminders (runs every hour)
+SELECT cron.schedule(
+ 'hourly-event-reminders',
+ '0 * * * *', -- Every hour
+ $$
+ SELECT net.http_post(
+ url := 'https://your-project.supabase.co/functions/v1/event-reminders',
+ headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
+ body := '{}'::jsonb
+ );
+ $$
+);
+```
+
+### 7.13 Email Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| Receive automated emails | ✓ | ✓ | ✓ |
+| View own email history | ✓ | ✓ | ✓ |
+| View all email logs | - | - | ✓ |
+| Edit email templates | - | - | ✓ |
+| Send broadcast emails | - | - | ✓ |
+| Send manual reminders | - | ✓ | ✓ |
+| Configure email settings | - | - | ✓ |
+| Test email connection | - | - | ✓ |
+
+---
+
+## Phase 1: Current System Analysis (COMPLETED)
+
+### Current Tech Stack
+| Layer | Technology |
+|-------|------------|
+| Framework | Nuxt 3 (Vue 3) - SSR disabled, CSR-only |
+| UI Components | Vuetify 3 + Tailwind CSS + Custom SCSS |
+| Database | NocoDB (REST API over PostgreSQL) |
+| Authentication | Keycloak (OAuth2/OIDC) |
+| File Storage | MinIO (S3-compatible) |
+| Email | Nodemailer + Handlebars templates |
+| State | Vue Composition API (no Pinia) |
+
+### Current Features Inventory
+
+#### 1. Member Management
+- **Data Model**: Member with 20+ fields (name, email, phone, DOB, nationality, address, etc.)
+- **Member ID Format**: `MUSA-YYYY-XXXX` (auto-generated)
+- **Status States**: Active, Inactive, Pending, Expired
+- **Portal Tiers**: admin, board, user
+- **Profile Images**: Stored in MinIO
+
+#### 2. Dues/Subscription System
+- **Calculation**: Due 1 year after last payment
+- **Fields Tracked**: `membership_date_paid`, `payment_due_date`, `current_year_dues_paid`
+- **Reminders**: 30-day advance warning, overdue notifications
+- **Auto-Status**: Members 1+ year overdue marked Inactive
+- **Rates**: €50 regular, €25 student, €35 senior, €75 family, €200 corporate
+
+#### 3. Events System
+- **Event Types**: meeting, social, fundraiser, workshop, board-only
+- **RSVP System**: confirmed, declined, pending, waitlist
+- **Guest Management**: Extra guests per RSVP
+- **Pricing**: Member vs non-member pricing
+- **Visibility**: public, board-only, admin-only
+- **Calendar**: FullCalendar integration with iCal feed
+
+#### 4. Authentication & Authorization
+- **Login Methods**: OAuth (Keycloak) + Direct login (ROPC)
+- **Role System**: Keycloak realm roles (monaco-admin, monaco-board, monaco-user)
+- **Session**: Server-side with HTTP-only cookies (7-30 days)
+- **Rate Limiting**: 5 attempts/15min, 1-hour IP block
+- **Signup Flow**: Form → reCAPTCHA → Keycloak user → NocoDB member → Verification email
+
+#### 5. Three Dashboard Types
+- **Admin**: Full system control, user management, settings, all features
+- **Board**: Member directory, dues management, events, meetings, governance
+- **Member**: Personal profile, events, payments, resources
+
+### Current Pain Points (to address in rebuild)
+1. CSR-only limits SEO and initial load performance
+2. NocoDB adds complexity vs direct database access
+3. String booleans ("true"/"false") cause type issues
+4. No payment history table (only last payment tracked)
+5. Vuetify + Tailwind overlap creates CSS conflicts
+6. Large monolithic layout files (700-800+ lines each)
+
+---
+
+## Phase 2: New System Requirements
+
+### Core Requirements (confirmed)
+- [x] Beautiful, modern, responsive frontend (not generic Vue look)
+- [x] Member tracking with subscription/dues management
+- [x] Dues due 1 year after last payment
+- [x] Event calendar with RSVP system
+- [x] Board members can create/manage events
+- [x] Event features: capacity, +1 guests, member/non-member pricing
+- [x] Three dashboard types: Admin, Board, Member
+- [x] Signup system similar to current
+- [x] **Manual payment tracking only** (no Stripe integration)
+- [x] **Email notifications** (dues reminders, event updates)
+- [x] **Document storage** (meeting minutes, governance docs)
+
+### Deployment Strategy
+- **Replace entirely** - Switch over when ready (no parallel systems)
+- **Manual data entry** - Members will be entered manually (no migration scripts)
+
+---
+
+## FRONTEND FRAMEWORK OPTIONS
+
+### Tier 1: Modern & Distinctive (Recommended)
+
+#### 1. **Qwik + QwikCity** ⭐ MOST INNOVATIVE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Resumable framework - HTML loads instantly, JS loads on-demand |
+| **Unique Feature** | Zero hydration - fastest possible load times |
+| **Learning Curve** | Medium (JSX-like but different mental model) |
+| **Ecosystem** | Growing - Auth.js, Drizzle, Modular Forms integrations |
+| **Benchmark Score** | 93.8 (highest among all frameworks) |
+
+**Pros:**
+- Blazing fast initial load (no hydration delay)
+- Feels like React/JSX but with better performance model
+- Built-in form handling with Zod validation
+- Server functions with `"use server"` directive
+- Excellent TypeScript support
+- Unique - won't look like every other site
+
+**Cons:**
+- Smaller community than React/Vue
+- Fewer pre-built component libraries
+- Newer framework (less battle-tested in production)
+- Some patterns feel unfamiliar at first
+
+**Best For:** Performance-focused apps where first impression matters
+
+---
+
+#### 2. **SolidStart (Solid.js)** ⭐ MOST PERFORMANT REACTIVITY
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Fine-grained reactive framework with meta-framework |
+| **Unique Feature** | No Virtual DOM - direct DOM updates via signals |
+| **Learning Curve** | Medium (React-like JSX, different reactivity) |
+| **Ecosystem** | Good - Ark UI, Kobalte for components |
+| **Benchmark Score** | 92.2 |
+
+**Pros:**
+- Smallest bundle sizes in the industry
+- React-like syntax (easy transition)
+- True reactivity (no re-renders, just updates)
+- Server functions and data loading built-in
+- Growing rapidly in popularity
+- Unique performance characteristics
+
+**Cons:**
+- Smaller ecosystem than React
+- Fewer tutorials and resources
+- Some React patterns don't translate directly
+- Component libraries less mature
+
+**Best For:** Highly interactive dashboards with lots of real-time updates
+
+---
+
+#### 3. **SvelteKit** ⭐ BEST DEVELOPER EXPERIENCE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Compiler-based framework with full-stack capabilities |
+| **Unique Feature** | No virtual DOM, compiles to vanilla JS |
+| **Learning Curve** | Low (closest to vanilla HTML/CSS/JS) |
+| **Ecosystem** | Strong - Skeleton UI, Melt UI, Shadcn-Svelte |
+| **Benchmark Score** | 91.0 |
+
+**Pros:**
+- Simplest syntax - looks like enhanced HTML
+- Smallest learning curve
+- Excellent built-in animations/transitions
+- Strong TypeScript integration
+- Great form handling
+- Active, helpful community
+- Svelte 5 runes make state even simpler
+
+**Cons:**
+- Different mental model from React/Vue
+- Smaller job market (if that matters)
+- Some advanced patterns less documented
+- Breaking changes between Svelte 4 and 5
+
+**Best For:** Clean, maintainable code with minimal boilerplate
+
+---
+
+#### 4. **Astro + React/Vue/Svelte Islands** ⭐ MOST FLEXIBLE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Content-focused framework with "islands" of interactivity |
+| **Unique Feature** | Mix multiple frameworks, zero JS by default |
+| **Learning Curve** | Low-Medium |
+| **Ecosystem** | Excellent - use ANY UI library |
+| **Benchmark Score** | 90.2 |
+
+**Pros:**
+- Use React, Vue, Svelte, or Solid components together
+- Zero JavaScript shipped by default
+- Excellent for content + interactive sections
+- Built-in image optimization
+- Great Supabase integration documented
+- View Transitions API support
+
+**Cons:**
+- Not ideal for highly interactive SPAs
+- Island architecture adds complexity
+- More configuration for full interactivity
+- Less unified than single-framework approach
+
+**Best For:** Marketing site + member portal hybrid
+
+---
+
+### Tier 2: Battle-Tested Mainstream
+
+#### 5. **Next.js 15 (React)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Most popular React meta-framework |
+| **Unique Feature** | App Router, Server Components, huge ecosystem |
+| **Learning Curve** | Medium-High (lots of concepts) |
+| **Ecosystem** | Largest - shadcn/ui, Radix, everything |
+| **Benchmark Score** | N/A (didn't query) |
+
+**Pros:**
+- Largest ecosystem and community
+- Most job opportunities
+- shadcn/ui provides beautiful, customizable components
+- Excellent documentation
+- Vercel hosting optimized
+
+**Cons:**
+- Can feel "generic" - many sites use it
+- Complex mental model (Server vs Client components)
+- Heavier than alternatives
+- Vercel-centric development
+
+---
+
+#### 6. **Remix**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Full-stack React framework focused on web standards |
+| **Unique Feature** | Nested routing, progressive enhancement |
+| **Learning Curve** | Medium |
+| **Ecosystem** | Good - React ecosystem compatible |
+| **Benchmark Score** | 89.4 |
+
+**Pros:**
+- Web standards focused (works without JS)
+- Excellent data loading patterns
+- Great error handling
+- Form handling is first-class
+- Can deploy anywhere (not Vercel-locked)
+
+**Cons:**
+- Smaller community than Next.js
+- Less "magic" means more manual work
+- Merged with React Router (transition period)
+
+---
+
+#### 7. **TanStack Start (React)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | New full-stack framework from TanStack team |
+| **Unique Feature** | Type-safe from database to UI |
+| **Learning Curve** | Medium |
+| **Ecosystem** | TanStack Query, Form, Router built-in |
+| **Benchmark Score** | 80.7 |
+
+**Pros:**
+- Built by TanStack (Query, Router, Form authors)
+- End-to-end type safety
+- Modern patterns throughout
+- Excellent data fetching built-in
+
+**Cons:**
+- Very new (beta/early stage)
+- Smaller community
+- Less documentation
+- Rapidly evolving API
+
+---
+
+#### 8. **Nuxt 4 (Vue 3)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Latest Vue meta-framework |
+| **Unique Feature** | Familiar from current system |
+| **Learning Curve** | Low (you know it) |
+| **Ecosystem** | Good - Nuxt UI, PrimeVue |
+
+**Pros:**
+- Familiar - no learning curve
+- Can reuse some current code/patterns
+- Strong conventions
+- Good TypeScript support now
+
+**Cons:**
+- User specifically wants to avoid "generic Vue look"
+- Similar limitations to current system
+- Less innovative than alternatives
+
+---
+
+#### 9. **Angular 19**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Google's enterprise framework |
+| **Unique Feature** | Signals, standalone components, full framework |
+| **Learning Curve** | High |
+| **Ecosystem** | Enterprise-grade |
+| **Benchmark Score** | 90.3 |
+
+**Pros:**
+- Complete framework (no decisions to make)
+- Excellent for large applications
+- Strong typing throughout
+- Signals in Angular 19 are modern
+
+**Cons:**
+- Steeper learning curve
+- More verbose
+- "Enterprise" feel may not fit small org
+- Overkill for this scale
+
+---
+
+### Tier 3: Experimental/Niche
+
+#### 10. **Leptos (Rust)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Full-stack Rust framework |
+| **Unique Feature** | WASM-based, extremely fast |
+| **Benchmark Score** | 89.7 |
+
+**Pros:**
+- Blazing fast (Rust + WASM)
+- Type safety at compile time
+- Innovative approach
+
+**Cons:**
+- Requires learning Rust
+- Small ecosystem
+- Harder to find developers
+- Overkill for this use case
+
+---
+
+#### 11. **Hono + HTMX**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Lightweight backend + hypermedia frontend |
+| **Unique Feature** | Server-rendered, minimal JS |
+| **Benchmark Score** | 92.8 |
+
+**Pros:**
+- Extremely lightweight
+- Simple mental model
+- Works on edge (Cloudflare Workers)
+- Fast development
+
+**Cons:**
+- Less rich interactivity
+- Different paradigm (hypermedia)
+- Limited complex UI patterns
+- Manual work for dashboards
+
+---
+
+## UI COMPONENT LIBRARY OPTIONS
+
+### For React-based Frameworks (Next.js, Remix, TanStack)
+
+| Library | Style | Customizable | Notes |
+|---------|-------|--------------|-------|
+| **shadcn/ui** | Modern, clean | Fully (copy/paste) | Most popular, highly customizable |
+| **Radix Themes** | Polished | Theme-based | Beautiful defaults, less work |
+| **Radix Primitives** | Unstyled | Fully | Build completely custom |
+| **Ark UI** | Unstyled | Fully | Works with multiple frameworks |
+| **Park UI** | Pre-styled Ark | Moderate | Ark + beautiful defaults |
+
+### For Solid.js
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **Kobalte** | Unstyled | Radix-like primitives for Solid |
+| **Ark UI Solid** | Unstyled | Same Ark, Solid version |
+| **Solid UI** | Various | Community components |
+
+### For Svelte
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **shadcn-svelte** | Modern | Port of shadcn for Svelte |
+| **Skeleton UI** | Tailwind | Full design system |
+| **Melt UI** | Unstyled | Primitives for Svelte |
+| **Bits UI** | Unstyled | Headless components |
+
+### For Qwik
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **Qwik UI** | Official | Growing component library |
+| **Custom + Tailwind** | Any | Build from scratch |
+
+### For Vue/Nuxt
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **shadcn-vue** | Modern | Port of shadcn for Vue |
+| **Radix Vue** | Unstyled | Radix primitives for Vue |
+| **Nuxt UI** | Tailwind | Official Nuxt components |
+| **PrimeVue** | Various | Comprehensive but generic |
+
+---
+
+## DATABASE OPTIONS - DETAILED COMPARISON
+
+### Option 1: **Supabase** ⭐ RECOMMENDED
+| Aspect | Details |
+|--------|---------|
+| **Type** | PostgreSQL + Auth + Storage + Realtime |
+| **Hosting** | Managed cloud or self-hosted |
+| **Pricing** | Free tier, then $25/mo |
+
+**Pros:**
+- All-in-one: Database + Auth + File Storage + Realtime
+- PostgreSQL (industry standard, powerful)
+- Row-level security built-in
+- Excellent TypeScript support
+- Auto-generated APIs
+- Real-time subscriptions
+- Built-in auth (replaces Keycloak)
+- Dashboard for data management
+- Can self-host if needed
+
+**Cons:**
+- Vendor lock-in (mitigated by self-host option)
+- Learning curve for RLS policies
+- Free tier has limits
+- Less control than raw PostgreSQL
+
+**Best For:** Rapid development with full-stack features
+
+---
+
+### Option 2: **PostgreSQL + Prisma**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Direct database + Type-safe ORM |
+| **Hosting** | Any PostgreSQL host (Neon, Railway, etc.) |
+| **Pricing** | Database hosting costs only |
+
+**Pros:**
+- Full control over database
+- Prisma schema is very readable
+- Excellent TypeScript types
+- Migrations handled automatically
+- Works with any PostgreSQL
+- Large community
+
+**Cons:**
+- Need separate auth solution
+- Need separate file storage
+- More setup work
+- Prisma can be slow for complex queries
+
+**Best For:** Maximum control and flexibility
+
+---
+
+### Option 3: **PostgreSQL + Drizzle ORM**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Direct database + Lightweight ORM |
+| **Hosting** | Any PostgreSQL host |
+| **Pricing** | Database hosting costs only |
+
+**Pros:**
+- Closer to SQL (less abstraction)
+- Faster than Prisma
+- Smaller bundle size
+- TypeScript-first
+- Better for complex queries
+- Growing rapidly
+
+**Cons:**
+- Newer, smaller community
+- Less documentation
+- Need separate auth/storage
+- More manual migration work
+
+**Best For:** Performance-critical apps, SQL-comfortable teams
+
+---
+
+### Option 4: **PlanetScale + Drizzle**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Serverless MySQL |
+| **Hosting** | Managed cloud only |
+| **Pricing** | Free tier, then usage-based |
+
+**Pros:**
+- Serverless scaling
+- Branching (like git for databases)
+- No connection limits
+- Fast globally
+
+**Cons:**
+- MySQL not PostgreSQL
+- No foreign keys (by design)
+- Vendor lock-in
+- Can get expensive at scale
+
+**Best For:** Serverless deployments, edge functions
+
+---
+
+### Option 5: **Keep NocoDB**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Spreadsheet-like interface over database |
+| **Hosting** | Self-hosted or cloud |
+
+**Pros:**
+- Already configured
+- Non-technical users can edit data
+- Flexible schema changes
+- API already exists
+
+**Cons:**
+- Adds complexity layer
+- String booleans issue
+- Less type safety
+- Performance overhead
+- Limited query capabilities
+
+**Best For:** Non-technical admin users need direct access
+
+---
+
+## AUTHENTICATION OPTIONS - DETAILED COMPARISON
+
+### Option 1: **Supabase Auth** ⭐ IF USING SUPABASE
+| Aspect | Details |
+|--------|---------|
+| **Type** | Built into Supabase |
+| **Providers** | Email, OAuth (Google, GitHub, etc.), Magic Link |
+
+**Pros:**
+- Integrated with Supabase (one platform)
+- Row-level security integration
+- Simple setup
+- Built-in user management
+- Social logins included
+- Magic link support
+
+**Cons:**
+- Tied to Supabase
+- Less customizable than Keycloak
+- No SAML/enterprise SSO on free tier
+
+---
+
+### Option 2: **Keep Keycloak**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Self-hosted identity provider |
+| **Providers** | Everything (OIDC, SAML, social, etc.) |
+
+**Pros:**
+- Already configured and working
+- Enterprise-grade features
+- Full control
+- SAML support
+- Custom themes
+- User federation
+
+**Cons:**
+- Complex to maintain
+- Heavy resource usage
+- Overkill for small org
+- Requires Java expertise
+- Self-hosted burden
+
+---
+
+### Option 3: **Better Auth** ⭐ MODERN CHOICE
+| Aspect | Details |
+|--------|---------|
+| **Type** | Framework-agnostic TypeScript auth |
+| **Providers** | Email, OAuth, Magic Link, Passkeys |
+
+**Pros:**
+- Modern, TypeScript-first
+- Works with any framework
+- Plugin system for features
+- Session management built-in
+- Two-factor auth support
+- Lightweight
+
+**Cons:**
+- Newer (less battle-tested)
+- Self-implemented
+- Need own user storage
+
+---
+
+### Option 4: **Auth.js (NextAuth)**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Framework-agnostic auth library |
+| **Providers** | 50+ OAuth providers |
+
+**Pros:**
+- Massive provider support
+- Well documented
+- Active development
+- Works with Qwik, SvelteKit, etc.
+
+**Cons:**
+- Complex configuration
+- Database adapter setup
+- v5 migration issues
+- Can be finicky
+
+---
+
+### Option 5: **Clerk**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Auth-as-a-service |
+| **Providers** | Everything + beautiful UI |
+
+**Pros:**
+- Beautiful pre-built components
+- Zero config setup
+- Great DX
+- Organizations/teams built-in
+
+**Cons:**
+- Expensive at scale
+- Vendor lock-in
+- Less control
+- Monthly costs
+
+---
+
+### Option 6: **Lucia Auth**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Low-level auth library |
+| **Note** | Being deprecated in favor of guides |
+
+**Pros:**
+- Full control
+- Lightweight
+- Educational
+
+**Cons:**
+- Being sunset
+- More DIY work
+
+---
+
+## CHOSEN STACK (FINAL)
+
+| Layer | Technology | Rationale |
+|-------|------------|-----------|
+| **Framework** | SvelteKit 2 | Best DX, simple syntax, excellent performance |
+| **UI Components** | shadcn-svelte + Bits UI | Beautiful, customizable, accessible |
+| **Styling** | Tailwind CSS 4 | Utility-first, works great with shadcn |
+| **Database** | Supabase (PostgreSQL) | All-in-one, managed, real-time capable |
+| **Auth** | Supabase Auth | Integrated with database, simple setup |
+| **File Storage** | Supabase Storage | Profile images, documents |
+| **Design** | Glass-morphism (evolved) | Modern, distinctive, refined |
+| **Language** | TypeScript | Type safety throughout |
+
+---
+
+## Phase 3: Architecture Design
+
+### Project Structure
+
+```
+monacousa-portal-2026/
+├── src/
+│ ├── lib/
+│ │ ├── components/ # Reusable UI components
+│ │ │ ├── ui/ # shadcn-svelte base components
+│ │ │ ├── dashboard/ # Dashboard widgets
+│ │ │ ├── members/ # Member-related components
+│ │ │ ├── events/ # Event components
+│ │ │ └── layout/ # Layout components (sidebar, header)
+│ │ ├── server/ # Server-only utilities
+│ │ │ ├── supabase.ts # Supabase server client
+│ │ │ └── auth.ts # Auth helpers
+│ │ ├── stores/ # Svelte stores for state
+│ │ ├── utils/ # Shared utilities
+│ │ │ ├── types.ts # TypeScript types
+│ │ │ ├── constants.ts # App constants
+│ │ │ └── helpers.ts # Helper functions
+│ │ └── supabase.ts # Supabase client (browser)
+│ │
+│ ├── routes/
+│ │ ├── +layout.svelte # Root layout
+│ │ ├── +layout.server.ts # Root server load (auth)
+│ │ ├── +page.svelte # Landing page
+│ │ │
+│ │ ├── (auth)/ # Auth group (guest only)
+│ │ │ ├── login/
+│ │ │ ├── signup/
+│ │ │ ├── forgot-password/
+│ │ │ └── callback/ # OAuth callback
+│ │ │
+│ │ ├── (app)/ # Protected app group
+│ │ │ ├── +layout.svelte # App layout with sidebar
+│ │ │ ├── +layout.server.ts # Auth guard
+│ │ │ │
+│ │ │ ├── dashboard/ # User dashboard
+│ │ │ ├── profile/ # User profile
+│ │ │ ├── events/ # Events calendar/list
+│ │ │ ├── payments/ # Dues/payments view
+│ │ │ │
+│ │ │ ├── board/ # Board-only routes
+│ │ │ │ ├── +layout.server.ts # Board guard
+│ │ │ │ ├── dashboard/
+│ │ │ │ ├── members/
+│ │ │ │ ├── events/ # Event management
+│ │ │ │ └── meetings/
+│ │ │ │
+│ │ │ └── admin/ # Admin-only routes
+│ │ │ ├── +layout.server.ts # Admin guard
+│ │ │ ├── dashboard/
+│ │ │ ├── members/
+│ │ │ ├── users/
+│ │ │ ├── events/
+│ │ │ └── settings/
+│ │ │
+│ │ └── api/ # API routes (if needed)
+│ │
+│ ├── hooks.server.ts # Server hooks (Supabase SSR)
+│ └── app.d.ts # TypeScript declarations
+│
+├── static/ # Static assets
+├── supabase/ # Supabase local dev
+│ └── migrations/ # Database migrations
+├── tests/ # Test files
+├── svelte.config.js
+├── tailwind.config.ts
+├── vite.config.ts
+└── package.json
+```
+
+---
+
+### Database Schema (Supabase/PostgreSQL)
+
+```sql
+-- USERS (managed by Supabase Auth)
+-- auth.users table is automatic
+
+-- MEMBERS (extends auth.users)
+CREATE TABLE public.members (
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ member_id TEXT UNIQUE NOT NULL, -- MUSA-2026-0001 format
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ phone TEXT,
+ date_of_birth DATE,
+ address TEXT,
+ nationality TEXT[], -- Array of country codes ['FR', 'US']
+
+ -- Membership
+ role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')),
+ membership_status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (membership_status IN ('active', 'inactive', 'pending', 'expired')),
+ member_since DATE DEFAULT CURRENT_DATE,
+
+ -- Profile
+ avatar_url TEXT,
+ bio TEXT,
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- DUES/PAYMENTS (tracks payment history)
+CREATE TABLE public.dues_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ amount DECIMAL(10,2) NOT NULL,
+ currency TEXT DEFAULT 'EUR',
+ payment_date DATE NOT NULL,
+ due_date DATE NOT NULL, -- When this payment period ends
+ payment_method TEXT, -- 'bank_transfer', 'cash', etc.
+ reference TEXT, -- Transaction reference
+ notes TEXT,
+
+ recorded_by UUID REFERENCES public.members(id), -- Who recorded this payment
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EVENTS
+CREATE TABLE public.events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title TEXT NOT NULL,
+ description TEXT,
+ event_type TEXT NOT NULL CHECK (event_type IN ('social', 'meeting', 'fundraiser', 'workshop', 'other')),
+
+ start_datetime TIMESTAMPTZ NOT NULL,
+ end_datetime TIMESTAMPTZ NOT NULL,
+ location TEXT,
+
+ -- Capacity & Pricing
+ max_attendees INTEGER,
+ max_guests_per_member INTEGER DEFAULT 1,
+ member_price DECIMAL(10,2) DEFAULT 0,
+ non_member_price DECIMAL(10,2) DEFAULT 0,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+ status TEXT NOT NULL DEFAULT 'published'
+ CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
+
+ -- Metadata
+ created_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EVENT RSVPs
+CREATE TABLE public.event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+
+ attended BOOLEAN DEFAULT FALSE,
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, member_id)
+);
+
+-- DOCUMENTS (meeting minutes, governance, etc.)
+CREATE TABLE public.documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title TEXT NOT NULL,
+ description TEXT,
+ category TEXT NOT NULL CHECK (category IN ('meeting_minutes', 'governance', 'financial', 'other')),
+
+ file_path TEXT NOT NULL, -- Supabase Storage path
+ file_name TEXT NOT NULL,
+ file_size INTEGER,
+ mime_type TEXT,
+
+ visibility TEXT NOT NULL DEFAULT 'board'
+ CHECK (visibility IN ('members', 'board', 'admin')),
+
+ uploaded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EMAIL NOTIFICATIONS LOG
+CREATE TABLE public.email_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ recipient_id UUID REFERENCES public.members(id),
+ recipient_email TEXT NOT NULL,
+ email_type TEXT NOT NULL, -- 'dues_reminder', 'event_invite', etc.
+ subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'sent'
+ CHECK (status IN ('sent', 'failed', 'bounced')),
+
+ sent_at TIMESTAMPTZ DEFAULT NOW(),
+ error_message TEXT
+);
+
+-- COMPUTED VIEW: Member with dues status
+CREATE VIEW public.members_with_dues AS
+SELECT
+ m.*,
+ dp.payment_date as last_payment_date,
+ dp.due_date as current_due_date,
+ CASE
+ WHEN dp.due_date IS NULL THEN 'never_paid'
+ WHEN dp.due_date < CURRENT_DATE THEN 'overdue'
+ WHEN dp.due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
+ ELSE 'current'
+ END as dues_status,
+ CASE
+ WHEN dp.due_date < CURRENT_DATE
+ THEN CURRENT_DATE - dp.due_date
+ ELSE NULL
+ END as days_overdue
+FROM public.members m
+LEFT JOIN LATERAL (
+ SELECT payment_date, due_date
+ FROM public.dues_payments
+ WHERE member_id = m.id
+ ORDER BY due_date DESC
+ LIMIT 1
+) dp ON true;
+
+-- ROW LEVEL SECURITY
+ALTER TABLE public.members ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.dues_payments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.events ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.event_rsvps ENABLE ROW LEVEL SECURITY;
+
+-- Members: Users can read all, update own, admins can do anything
+CREATE POLICY "Members are viewable by authenticated users"
+ ON public.members FOR SELECT
+ TO authenticated
+ USING (true);
+
+CREATE POLICY "Users can update own profile"
+ ON public.members FOR UPDATE
+ TO authenticated
+ USING (auth.uid() = id);
+
+CREATE POLICY "Admins can insert members"
+ ON public.members FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ OR auth.uid() = id -- Self-registration
+ );
+
+-- Events: Based on visibility
+CREATE POLICY "Events viewable based on visibility"
+ ON public.events FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ );
+
+-- Board/Admin can manage events
+CREATE POLICY "Board can manage events"
+ ON public.events FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+```
+
+---
+
+### Authentication Flow
+
+```
+1. SIGNUP FLOW
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ /signup │────▶│ Supabase Auth│────▶│ Email Verify│
+ │ Form │ │ signUp() │ │ Link Sent │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Create Member│
+ │ Record (RLS) │
+ └──────────────┘
+
+2. LOGIN FLOW
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ /login │────▶│ Supabase Auth│────▶│ Set Session │
+ │ Form │ │ signIn() │ │ Cookie │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Redirect to │
+ │ Dashboard │
+ └──────────────┘
+
+3. PROTECTED ROUTES
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ Request │────▶│ hooks.server │────▶│ Check Role │
+ │ /admin/* │ │ getSession() │ │ in members │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │ │
+ ▼ ▼
+ ┌──────────────┐ ┌─────────────┐
+ │ Valid? │ │ Redirect or │
+ │ Yes → Render │ │ 403 Error │
+ └──────────────┘ └─────────────┘
+```
+
+---
+
+### UI Component Library
+
+Using **shadcn-svelte** with custom glass-morphism theme:
+
+```typescript
+// tailwind.config.ts - Glass theme extensions
+export default {
+ theme: {
+ extend: {
+ colors: {
+ monaco: {
+ 50: '#fef2f2',
+ 100: '#fee2e2',
+ 500: '#ef4444',
+ 600: '#dc2626', // Primary
+ 700: '#b91c1c',
+ 900: '#7f1d1d',
+ }
+ },
+ backdropBlur: {
+ xs: '2px',
+ },
+ boxShadow: {
+ 'glass': '0 8px 32px rgba(0, 0, 0, 0.1)',
+ 'glass-lg': '0 25px 50px rgba(0, 0, 0, 0.15)',
+ }
+ }
+ }
+}
+```
+
+**Custom Glass Components:**
+- `GlassCard` - Frosted glass container
+- `GlassSidebar` - Navigation sidebar
+- `GlassButton` - Glass-effect buttons
+- `GlassInput` - Form inputs with glass styling
+- `StatCard` - Dashboard stat display
+- `EventCard` - Event display card
+- `MemberCard` - Member profile card
+- `DuesStatusBadge` - Dues status indicator
+
+---
+
+### Key Features Implementation
+
+#### 1. Member Management
+- View all members (admin/board)
+- Edit member details
+- Upload profile photos (Supabase Storage)
+- Track membership status
+- Filter by status, nationality, dues
+
+#### 2. Dues Tracking
+- Payment history table
+- Auto-calculate due dates (1 year from payment)
+- Visual status indicators
+- Overdue notifications
+- Manual payment recording
+
+#### 3. Event System
+- Calendar view (FullCalendar or custom)
+- List view with filters
+- RSVP with guest management
+- Attendance tracking
+- Event creation (board/admin)
+
+#### 4. Three Dashboards
+| Dashboard | Features |
+|-----------|----------|
+| **Member** | Profile, upcoming events, dues status, quick actions |
+| **Board** | Member stats, pending applications, dues overview, event management |
+| **Admin** | System stats, user management, all member data, settings |
+
+#### 5. Email Notifications
+- Dues reminder emails (30 days before, on due date, overdue)
+- Event invitation/updates
+- Welcome email on signup
+- Password reset emails (Supabase built-in)
+
+#### 6. Document Storage
+- Upload meeting minutes, governance docs
+- Organize by category
+- Visibility controls (members/board/admin)
+- Download/preview functionality
+
+---
+
+## Phase 4: Implementation Roadmap
+
+### Stage 1: Foundation (Week 1-2)
+1. Initialize SvelteKit project with TypeScript
+2. Set up Tailwind CSS 4 + shadcn-svelte
+3. Configure Supabase project
+4. Create database schema + migrations
+5. Implement Supabase SSR hooks
+6. Build base layout components
+
+### Stage 2: Authentication (Week 2-3)
+1. Login/Signup pages
+2. Email verification flow
+3. Password reset
+4. Protected route guards
+5. Role-based access control
+
+### Stage 3: Core Features (Week 3-5)
+1. Member dashboard
+2. Profile management
+3. Member directory (board/admin)
+4. Dues tracking system
+5. Payment recording
+
+### Stage 4: Events (Week 5-6)
+1. Event listing/calendar
+2. Event detail view
+3. RSVP system
+4. Event creation (board)
+5. Attendance tracking
+
+### Stage 5: Admin Features (Week 6-7)
+1. Admin dashboard
+2. User management
+3. System settings
+4. Data export
+
+### Stage 6: Polish (Week 7-8)
+1. Glass-morphism styling refinement
+2. Responsive design
+3. Performance optimization
+4. Testing
+5. Documentation
+
+---
+
+## Verification Plan
+
+### Development Testing
+```bash
+# Start Supabase local
+npx supabase start
+
+# Run dev server
+npm run dev
+
+# Type checking
+npm run check
+```
+
+### Manual Testing Checklist
+- [ ] User can sign up and receive verification email
+- [ ] User can log in and see dashboard
+- [ ] Member can view/edit profile
+- [ ] Member can view events and RSVP
+- [ ] Board member can access board dashboard
+- [ ] Board member can create/manage events
+- [ ] Board member can view member directory
+- [ ] Board member can record dues payments
+- [ ] Admin can access all features
+- [ ] Admin can manage user roles
+- [ ] Role-based routing works correctly
+- [ ] Responsive on mobile/tablet/desktop
+
+### Browser Testing
+- Chrome, Firefox, Safari, Edge
+- iOS Safari, Android Chrome
+
+---
+
+## Data Entry Strategy
+
+Since members will be entered manually (no automated migration):
+
+### Admin Setup
+1. Create first admin account via Supabase dashboard
+2. Manually set `role = 'admin'` in members table
+3. Admin can then add other members through the portal
+
+### Member Entry Options
+1. **Admin adds members** - Admin creates accounts for existing members
+2. **Self-registration** - Members sign up themselves
+3. **Invite system** - Admin sends email invites with signup links
+
+### Initial Launch Checklist
+- [ ] Admin account created and verified
+- [ ] Board member accounts created
+- [ ] Test member account for verification
+- [ ] Email templates configured (Supabase)
+
+---
+
+## Files to Create
+
+| Path | Purpose |
+|------|---------|
+| `monacousa-portal-2026/` | New project root |
+| `src/hooks.server.ts` | Supabase SSR setup |
+| `src/lib/supabase.ts` | Client initialization |
+| `src/lib/server/supabase.ts` | Server client |
+| `src/routes/+layout.svelte` | Root layout |
+| `src/routes/(auth)/login/+page.svelte` | Login page |
+| `src/routes/(auth)/signup/+page.svelte` | Signup page |
+| `src/routes/(app)/+layout.svelte` | App layout |
+| `src/routes/(app)/dashboard/+page.svelte` | Member dashboard |
+| `supabase/migrations/001_schema.sql` | Database schema |
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..43f85a0
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,230 @@
+# Monaco USA Portal - Production Deployment Guide
+
+## Prerequisites
+
+- Debian/Ubuntu server with root access
+- Domain DNS configured (portal.monacousa.org, api.monacousa.org, studio.monacousa.org)
+- Ports 80 and 443 open in firewall
+
+## Quick Start
+
+### 1. First-Time Server Setup
+
+```bash
+# Clone the repository
+git clone https://code.letsbe.solutions/matt/monacousa-portal.git
+cd monacousa-portal
+
+# Make deploy script executable
+chmod +x deploy.sh
+
+# Run first-time setup (installs Docker, configures firewall)
+sudo ./deploy.sh setup
+```
+
+### 2. Configure Environment
+
+```bash
+# Copy environment template
+cp .env.production.example .env
+
+# Generate secrets
+./deploy.sh generate-secrets
+
+# Edit environment file with your values
+nano .env
+```
+
+**Important environment variables to configure:**
+- `DOMAIN` - Your domain (e.g., portal.monacousa.org)
+- `POSTGRES_PASSWORD` - Strong database password
+- `JWT_SECRET` - 32+ character random string
+- `ANON_KEY` / `SERVICE_ROLE_KEY` - Generate at supabase.com/docs/guides/self-hosting#api-keys
+- `SMTP_*` - Email server settings
+
+### 3. Install and Configure Nginx
+
+```bash
+# Install nginx
+sudo apt install nginx certbot python3-certbot-nginx -y
+
+# Copy nginx config
+sudo cp nginx/portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org
+
+# Enable the site
+sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+
+# Remove default site if exists
+sudo rm -f /etc/nginx/sites-enabled/default
+
+# Test config
+sudo nginx -t
+
+# Reload nginx
+sudo systemctl reload nginx
+```
+
+### 4. Deploy Docker Services
+
+```bash
+# Deploy all services
+./deploy.sh deploy
+
+# Wait for services to be healthy (check status)
+./deploy.sh status
+```
+
+### 5. Get SSL Certificate
+
+```bash
+# Get SSL certificate (after Docker services are running)
+sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+
+# Test auto-renewal
+sudo certbot renew --dry-run
+```
+
+## Common Commands
+
+```bash
+# View logs
+./deploy.sh logs # All services
+./deploy.sh logs portal # Portal only
+./deploy.sh logs db # Database only
+
+# Service management
+./deploy.sh status # Check status
+./deploy.sh restart # Restart all services
+./deploy.sh stop # Stop all services
+
+# Database
+./deploy.sh backup # Backup database
+./deploy.sh restore backup.sql.gz # Restore from backup
+
+# Updates
+./deploy.sh update # Pull latest code and rebuild portal
+
+# Cleanup
+./deploy.sh cleanup # Remove unused Docker resources
+```
+
+## Architecture
+
+```
+ ┌─────────────────┐
+ │ Internet │
+ └────────┬────────┘
+ │
+ ┌────────┴────────┐
+ │ Nginx (Host) │
+ │ :80 / :443 │
+ │ SSL Termination│
+ └────────┬────────┘
+ │
+ ┌────────────────────┼────────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+ ┌─────────┐ ┌─────────┐ ┌─────────┐
+ │ Portal │ │ API │ │ Studio │
+ │ :7453 │ │ :7455 │ │ :7454 │
+ └────┬────┘ └────┬────┘ └────┬────┘
+ │ │ │
+ │ ┌────┴────┐ │
+ │ │ Kong │ │
+ │ │ Gateway │ │
+ │ └────┬────┘ │
+ │ │ │
+ ▼ ▼ ▼
+ ┌─────────────────────────────────────────────────┐
+ │ Docker Network │
+ │ ┌──────┐ ┌──────┐ ┌─────────┐ ┌──────────┐ │
+ │ │ DB │ │ Auth │ │ Storage │ │ Realtime │ │
+ │ └──────┘ └──────┘ └─────────┘ └──────────┘ │
+ └─────────────────────────────────────────────────┘
+```
+
+## Ports
+
+| Service | Internal Port | External (localhost) |
+|---------|---------------|---------------------|
+| Portal | 3000 | 7453 |
+| Studio | 3000 | 7454 |
+| Kong | 8000 | 7455 |
+
+## Troubleshooting
+
+### Services not starting
+
+```bash
+# Check Docker logs
+docker logs monacousa-portal
+docker logs monacousa-db
+docker logs monacousa-kong
+
+# Check if ports are in use
+sudo netstat -tlnp | grep -E '7453|7454|7455'
+```
+
+### Database connection issues
+
+```bash
+# Check database health
+docker exec monacousa-db pg_isready -U postgres
+
+# View database logs
+docker logs monacousa-db --tail=50
+```
+
+### Nginx issues
+
+```bash
+# Test config
+sudo nginx -t
+
+# Check error log
+sudo tail -f /var/log/nginx/error.log
+
+# Check portal access log
+sudo tail -f /var/log/nginx/portal.monacousa.org.error.log
+```
+
+### SSL certificate issues
+
+```bash
+# Renew certificates manually
+sudo certbot renew
+
+# Check certificate status
+sudo certbot certificates
+```
+
+## Backup Strategy
+
+### Automated Daily Backups
+
+Add to crontab (`crontab -e`):
+
+```bash
+# Daily database backup at 3 AM
+0 3 * * * /path/to/monacousa-portal/deploy.sh backup 2>&1 | logger -t monacousa-backup
+```
+
+### Backup Storage
+
+Backups are saved to the project directory as `backup_YYYYMMDD_HHMMSS.sql.gz`.
+
+Consider copying to remote storage:
+```bash
+# Copy to remote server
+scp backup_*.sql.gz user@backup-server:/backups/monacousa/
+```
+
+## Security Checklist
+
+- [ ] Strong passwords in .env file
+- [ ] Firewall enabled (only 80, 443, 22 open)
+- [ ] SSL certificate installed
+- [ ] Studio protected with basic auth
+- [ ] Regular backups configured
+- [ ] Log rotation configured
+- [ ] Fail2ban installed (optional)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0a6b0b8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,77 @@
+# Monaco USA Portal - SvelteKit Application
+# Multi-stage build for optimized production image
+
+# ============================================
+# Stage 1: Dependencies
+# ============================================
+FROM node:20-alpine AS deps
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json* ./
+
+# Install dependencies
+RUN npm ci
+
+# ============================================
+# Stage 2: Builder
+# ============================================
+FROM node:20-alpine AS builder
+WORKDIR /app
+
+# Copy package files first
+COPY package.json package-lock.json* ./
+
+# Install dependencies - use npm install instead of npm ci to properly
+# resolve platform-specific optional dependencies (rollup binaries)
+RUN rm -rf node_modules && npm install --legacy-peer-deps
+
+# Copy source files
+COPY . .
+
+# Build arguments for environment variables
+ARG PUBLIC_SUPABASE_URL
+ARG PUBLIC_SUPABASE_ANON_KEY
+ARG SUPABASE_SERVICE_ROLE_KEY
+
+# Set environment variables for build
+ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
+ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
+ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
+
+# Build the application
+RUN npm run build
+
+# Prune dev dependencies
+RUN npm prune --production
+
+# ============================================
+# Stage 3: Runner (Production)
+# ============================================
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+# Set production environment
+ENV NODE_ENV=production
+
+# Create non-root user for security
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy built application
+COPY --from=builder --chown=sveltekit:nodejs /app/build ./build
+COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
+COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json
+
+# Switch to non-root user
+USER sveltekit
+
+# Expose port
+EXPOSE 3000
+
+# Set runtime environment variables
+ENV HOST=0.0.0.0
+ENV PORT=3000
+
+# Start the application
+CMD ["node", "build"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..75842c4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project in the current directory
+npx sv create
+
+# create a new project in my-app
+npx sv create my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/build.log b/build.log
new file mode 100644
index 0000000..e69de29
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..05e10cb
--- /dev/null
+++ b/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "src/app.css",
+ "baseColor": "slate"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks"
+ },
+ "typescript": true,
+ "registry": "https://next.shadcn-svelte.com/registry"
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..1201afe
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,339 @@
+#!/bin/bash
+# Monaco USA Portal - Production Deployment Script
+# For Debian/Ubuntu Linux servers
+#
+# Usage: ./deploy.sh [command]
+# Commands:
+# setup - First-time setup (install Docker, configure firewall)
+# deploy - Build and start all services
+# update - Pull latest changes and rebuild portal
+# logs - View logs
+# status - Check service status
+# backup - Backup database
+# restore - Restore database from backup
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Configuration
+COMPOSE_FILE="docker-compose.nginx.yml"
+PROJECT_NAME="monacousa"
+
+log_info() {
+ echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+log_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+# Check if running as root
+check_root() {
+ if [ "$EUID" -ne 0 ]; then
+ log_error "Please run as root (sudo ./deploy.sh)"
+ exit 1
+ fi
+}
+
+# Install Docker and Docker Compose on Debian
+install_docker() {
+ log_info "Installing Docker..."
+
+ # Remove old versions
+ apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
+
+ # Install dependencies
+ apt-get update
+ apt-get install -y \
+ apt-transport-https \
+ ca-certificates \
+ curl \
+ gnupg \
+ lsb-release
+
+ # Add Docker's official GPG key
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+ chmod a+r /etc/apt/keyrings/docker.gpg
+
+ # Add repository
+ echo \
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+ # Install Docker
+ apt-get update
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+
+ # Start and enable Docker
+ systemctl start docker
+ systemctl enable docker
+
+ log_info "Docker installed successfully"
+}
+
+# Configure firewall
+configure_firewall() {
+ log_info "Configuring firewall..."
+
+ # Install ufw if not present
+ apt-get install -y ufw
+
+ # Allow SSH, HTTP, HTTPS
+ ufw allow ssh
+ ufw allow http
+ ufw allow https
+
+ # Enable firewall
+ ufw --force enable
+
+ log_info "Firewall configured (SSH, HTTP, HTTPS allowed)"
+}
+
+# First-time setup
+setup() {
+ check_root
+ log_info "Starting first-time setup..."
+
+ # Update system
+ apt-get update && apt-get upgrade -y
+
+ # Install Docker
+ install_docker
+
+ # Configure firewall
+ configure_firewall
+
+ # Install useful tools
+ apt-get install -y htop nano git apache2-utils
+
+ # Check for .env file
+ if [ ! -f .env ]; then
+ log_warn ".env file not found!"
+ log_info "Copy .env.production.example to .env and configure it:"
+ echo " cp .env.production.example .env"
+ echo " nano .env"
+ fi
+
+ log_info "Setup complete! Next steps:"
+ echo " 1. Configure .env file: nano .env"
+ echo " 2. Deploy: ./deploy.sh deploy"
+}
+
+# Generate secrets helper
+generate_secrets() {
+ log_info "Generating secrets..."
+ echo ""
+ echo "JWT_SECRET=$(openssl rand -base64 32)"
+ echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)"
+ echo "SECRET_KEY_BASE=$(openssl rand -base64 64)"
+ echo ""
+ log_info "Copy these values to your .env file"
+}
+
+# Deploy/start services
+deploy() {
+ log_info "Deploying Monaco USA Portal..."
+
+ # Check for .env file
+ if [ ! -f .env ]; then
+ log_error ".env file not found! Copy .env.production.example to .env first."
+ exit 1
+ fi
+
+ # Build and start
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d
+
+ log_info "Deployment complete!"
+ log_info "Waiting for services to be healthy..."
+ sleep 10
+
+ # Show status
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps
+
+ log_info "Portal should be available at https://\$(grep DOMAIN .env | cut -d '=' -f2)"
+}
+
+# Update and rebuild
+update() {
+ log_info "Updating Monaco USA Portal..."
+
+ # Pull latest code (if git repo)
+ if [ -d .git ]; then
+ git pull origin main
+ fi
+
+ # Rebuild only the portal service
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal
+
+ # Restart portal with zero downtime
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d --no-deps portal
+
+ log_info "Update complete!"
+}
+
+# View logs
+logs() {
+ local service=${1:-""}
+ if [ -z "$service" ]; then
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100
+ else
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100 $service
+ fi
+}
+
+# Check status
+status() {
+ log_info "Service Status:"
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps
+ echo ""
+ log_info "Resource Usage:"
+ docker stats --no-stream $(docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps -q)
+}
+
+# Backup database
+backup() {
+ local backup_file="backup_$(date +%Y%m%d_%H%M%S).sql"
+ log_info "Backing up database to $backup_file..."
+
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ pg_dump -U postgres postgres > "$backup_file"
+
+ # Compress
+ gzip "$backup_file"
+
+ log_info "Backup complete: ${backup_file}.gz"
+}
+
+# Restore database
+restore() {
+ local backup_file=$1
+ if [ -z "$backup_file" ]; then
+ log_error "Usage: ./deploy.sh restore "
+ exit 1
+ fi
+
+ log_warn "This will overwrite the current database!"
+ read -p "Are you sure? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ exit 1
+ fi
+
+ log_info "Restoring database from $backup_file..."
+
+ # Decompress if needed
+ if [[ "$backup_file" == *.gz ]]; then
+ gunzip -c "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ psql -U postgres postgres
+ else
+ cat "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ psql -U postgres postgres
+ fi
+
+ log_info "Restore complete!"
+}
+
+# Stop all services
+stop() {
+ log_info "Stopping all services..."
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME down
+ log_info "All services stopped"
+}
+
+# Restart all services
+restart() {
+ log_info "Restarting all services..."
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME restart
+ log_info "All services restarted"
+}
+
+# Clean up unused Docker resources
+cleanup() {
+ log_info "Cleaning up unused Docker resources..."
+ docker system prune -af --volumes
+ log_info "Cleanup complete"
+}
+
+# Show help
+help() {
+ echo "Monaco USA Portal - Deployment Script"
+ echo ""
+ echo "Usage: ./deploy.sh [command]"
+ echo ""
+ echo "Commands:"
+ echo " setup First-time server setup (install Docker, firewall)"
+ echo " generate-secrets Generate random secrets for .env"
+ echo " deploy Build and start all services"
+ echo " update Pull latest code and rebuild portal"
+ echo " stop Stop all services"
+ echo " restart Restart all services"
+ echo " status Show service status and resource usage"
+ echo " logs [service] View logs (optionally for specific service)"
+ echo " backup Backup database to file"
+ echo " restore Restore database from backup"
+ echo " cleanup Remove unused Docker resources"
+ echo " help Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " sudo ./deploy.sh setup # First-time setup"
+ echo " ./deploy.sh deploy # Deploy the portal"
+ echo " ./deploy.sh logs portal # View portal logs"
+ echo " ./deploy.sh backup # Backup database"
+}
+
+# Main command handler
+case "${1:-help}" in
+ setup)
+ setup
+ ;;
+ generate-secrets)
+ generate_secrets
+ ;;
+ deploy)
+ deploy
+ ;;
+ update)
+ update
+ ;;
+ stop)
+ stop
+ ;;
+ restart)
+ restart
+ ;;
+ status)
+ status
+ ;;
+ logs)
+ logs $2
+ ;;
+ backup)
+ backup
+ ;;
+ restore)
+ restore $2
+ ;;
+ cleanup)
+ cleanup
+ ;;
+ help|--help|-h)
+ help
+ ;;
+ *)
+ log_error "Unknown command: $1"
+ help
+ exit 1
+ ;;
+esac
diff --git a/dev-output.txt b/dev-output.txt
new file mode 100644
index 0000000..e69de29
diff --git a/dev.log b/dev.log
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml
new file mode 100644
index 0000000..7864136
--- /dev/null
+++ b/docker-compose.nginx.yml
@@ -0,0 +1,386 @@
+# Monaco USA Portal - Production Docker Compose (with Nginx on host)
+# For deployment on Debian/Linux servers using Nginx as reverse proxy
+#
+# Usage:
+# 1. Copy .env.production.example to .env
+# 2. Configure all environment variables
+# 3. Run: docker compose -f docker-compose.nginx.yml up -d
+#
+# Ports exposed to localhost (nginx proxies to these):
+# - 7453: Portal (SvelteKit)
+# - 7454: Studio (Supabase Dashboard)
+# - 7455: Kong (API Gateway)
+
+services:
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+ reservations:
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Supabase Studio (Dashboard)
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7454:3000"
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: https://api.${DOMAIN}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7455:8000"
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: https://api.${DOMAIN}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth
+
+ GOTRUE_SITE_URL: https://${DOMAIN}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT}
+ GOTRUE_SMTP_USER: ${SMTP_USER}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/verify
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/verify
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/verify
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/verify
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD}
+ DB_NAME: ${POSTGRES_DB}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7453:3000"
+ environment:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: https://${DOMAIN}
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ reservations:
+ memory: 256M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..aa8219b
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,440 @@
+# Monaco USA Portal - Production Docker Compose
+# For deployment on Debian/Linux servers with Traefik reverse proxy
+#
+# Usage:
+# 1. Copy .env.production.example to .env
+# 2. Configure all environment variables
+# 3. Run: docker compose -f docker-compose.prod.yml up -d
+#
+# Prerequisites:
+# - Docker and Docker Compose installed
+# - Domain DNS pointing to server IP
+# - Ports 80 and 443 open
+
+services:
+ # ============================================
+ # Traefik Reverse Proxy (SSL/HTTPS)
+ # ============================================
+ traefik:
+ image: traefik:v3.0
+ container_name: monacousa-traefik
+ restart: unless-stopped
+ command:
+ - "--api.dashboard=true"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+ - "--entrypoints.websecure.address=:443"
+ - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
+ - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
+ - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
+ - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
+ - "--log.level=INFO"
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - traefik-certs:/letsencrypt
+ networks:
+ - monacousa-network
+ labels:
+ # Traefik dashboard (optional - remove in production if not needed)
+ - "traefik.enable=true"
+ - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
+ - "traefik.http.routers.traefik.entrypoints=websecure"
+ - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.traefik.service=api@internal"
+ - "traefik.http.routers.traefik.middlewares=traefik-auth"
+ - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
+
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+ reservations:
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Supabase Studio (Dashboard) - Optional
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: https://api.${DOMAIN}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.studio.rule=Host(`studio.${DOMAIN}`)"
+ - "traefik.http.routers.studio.entrypoints=websecure"
+ - "traefik.http.routers.studio.tls.certresolver=letsencrypt"
+ - "traefik.http.services.studio.loadbalancer.server.port=3000"
+ - "traefik.http.routers.studio.middlewares=studio-auth"
+ - "traefik.http.middlewares.studio-auth.basicauth.users=${STUDIO_AUTH}"
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.kong.rule=Host(`api.${DOMAIN}`)"
+ - "traefik.http.routers.kong.entrypoints=websecure"
+ - "traefik.http.routers.kong.tls.certresolver=letsencrypt"
+ - "traefik.http.services.kong.loadbalancer.server.port=8000"
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: https://api.${DOMAIN}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth
+
+ GOTRUE_SITE_URL: https://${DOMAIN}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT}
+ GOTRUE_SMTP_USER: ${SMTP_USER}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/verify
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/verify
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/verify
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/verify
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD}
+ DB_NAME: ${POSTGRES_DB}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ environment:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: https://${DOMAIN}
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.portal.rule=Host(`${DOMAIN}`)"
+ - "traefik.http.routers.portal.entrypoints=websecure"
+ - "traefik.http.routers.portal.tls.certresolver=letsencrypt"
+ - "traefik.http.services.portal.loadbalancer.server.port=3000"
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ reservations:
+ memory: 256M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
+ traefik-certs:
+ driver: local
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..cd12f0f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,318 @@
+# Monaco USA Portal - Full Stack Docker Compose
+# Includes: PostgreSQL, Supabase Services, and SvelteKit App
+
+services:
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ ports:
+ - "${POSTGRES_PORT:-5435}:5432"
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ POSTGRES_DB: ${POSTGRES_DB:-postgres}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY:-3600}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Supabase Studio (Dashboard)
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ ports:
+ - "${STUDIO_PORT:-7454}:3000"
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:7455}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ ports:
+ - "${KONG_HTTP_PORT:-7455}:8000"
+ - "${KONG_HTTPS_PORT:-7456}:8443"
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:7455}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}?search_path=auth
+
+ GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
+ GOTRUE_SMTP_USER: ${SMTP_USER:-}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-noreply@monacousa.org}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Monaco USA}
+ GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE:-/auth/verify}
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT:-100}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public,storage,graphql_public}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ DB_NAME: ${POSTGRES_DB:-postgres}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ ports:
+ - "${PORTAL_PORT:-7453}:3000"
+ environment:
+ PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: http://localhost:7453
+ # Body size limit for file uploads (50MB)
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
diff --git a/nginx/portal.monacousa.org.conf b/nginx/portal.monacousa.org.conf
new file mode 100644
index 0000000..503710f
--- /dev/null
+++ b/nginx/portal.monacousa.org.conf
@@ -0,0 +1,244 @@
+# Monaco USA Portal - Nginx Configuration
+# Location: /etc/nginx/sites-available/portal.monacousa.org
+#
+# Installation:
+# 1. Copy to /etc/nginx/sites-available/
+# 2. Create symlink: ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+# 3. Test config: nginx -t
+# 4. Get SSL cert: certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+# 5. Reload: systemctl reload nginx
+
+# Rate limiting zone
+limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
+
+# Upstream definitions
+upstream portal_backend {
+ server 127.0.0.1:7453;
+ keepalive 32;
+}
+
+upstream api_backend {
+ server 127.0.0.1:7455;
+ keepalive 32;
+}
+
+upstream studio_backend {
+ server 127.0.0.1:7454;
+ keepalive 16;
+}
+
+# Main Portal - portal.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name portal.monacousa.org;
+
+ # Redirect all HTTP to HTTPS
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ # Let's Encrypt challenge
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name portal.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing (remove after certbot)
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ # Logging
+ access_log /var/log/nginx/portal.monacousa.org.access.log;
+ error_log /var/log/nginx/portal.monacousa.org.error.log;
+
+ # Client body size (for file uploads)
+ client_max_body_size 50M;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
+
+ # Rate limiting
+ limit_req zone=portal_limit burst=20 nodelay;
+
+ # Main application
+ location / {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Timeouts
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+
+ # Buffering
+ proxy_buffering on;
+ proxy_buffer_size 4k;
+ proxy_buffers 8 4k;
+ }
+
+ # Static assets with caching
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Cache static assets
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
+
+# Supabase API - api.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name api.monacousa.org;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name api.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Security headers
+ add_header X-Frame-Options "DENY" always;
+ add_header X-Content-Type-Options "nosniff" always;
+
+ # Logging
+ access_log /var/log/nginx/api.monacousa.org.access.log;
+ error_log /var/log/nginx/api.monacousa.org.error.log;
+
+ # Client body size
+ client_max_body_size 50M;
+
+ # Rate limiting (higher for API)
+ limit_req zone=api_limit burst=50 nodelay;
+
+ # CORS preflight
+ location / {
+ if ($request_method = 'OPTIONS') {
+ add_header 'Access-Control-Allow-Origin' '*';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, apikey, x-client-info';
+ add_header 'Access-Control-Max-Age' 1728000;
+ add_header 'Content-Type' 'text/plain charset=UTF-8';
+ add_header 'Content-Length' 0;
+ return 204;
+ }
+
+ proxy_pass http://api_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Longer timeout for realtime connections
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+ }
+}
+
+# Supabase Studio - studio.monacousa.org (optional, for admin access)
+server {
+ listen 80;
+ listen [::]:80;
+ server_name studio.monacousa.org;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name studio.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Basic auth protection for studio
+ auth_basic "Monaco USA Admin";
+ auth_basic_user_file /etc/nginx/.htpasswd;
+
+ # Logging
+ access_log /var/log/nginx/studio.monacousa.org.access.log;
+ error_log /var/log/nginx/studio.monacousa.org.error.log;
+
+ location / {
+ proxy_pass http://studio_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/nginx/portal.monacousa.org.initial.conf b/nginx/portal.monacousa.org.initial.conf
new file mode 100644
index 0000000..5e49ff1
--- /dev/null
+++ b/nginx/portal.monacousa.org.initial.conf
@@ -0,0 +1,161 @@
+# Monaco USA Portal - Initial Nginx Configuration (HTTP only)
+# Location: /etc/nginx/sites-available/portal.monacousa.org
+#
+# This is the initial config before running certbot.
+#
+# Installation:
+# 1. sudo cp portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org
+# 2. sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+# 3. sudo nginx -t
+# 4. sudo systemctl reload nginx
+# 5. sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+#
+# After certbot succeeds, it will automatically update this config with SSL settings.
+
+# Rate limiting zones
+limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
+
+# Upstream definitions
+upstream portal_backend {
+ server 127.0.0.1:7453;
+ keepalive 32;
+}
+
+upstream api_backend {
+ server 127.0.0.1:7455;
+ keepalive 32;
+}
+
+upstream studio_backend {
+ server 127.0.0.1:7454;
+ keepalive 16;
+}
+
+# Main Portal - portal.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name portal.monacousa.org;
+
+ # Let's Encrypt challenge
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/portal.monacousa.org.access.log;
+ error_log /var/log/nginx/portal.monacousa.org.error.log;
+
+ # Client body size (for file uploads)
+ client_max_body_size 50M;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
+
+ # Rate limiting
+ limit_req zone=portal_limit burst=20 nodelay;
+
+ # Main application
+ location / {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Timeouts
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+
+ # Buffering
+ proxy_buffering on;
+ proxy_buffer_size 4k;
+ proxy_buffers 8 4k;
+ }
+
+ # Static assets with caching
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Cache static assets
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
+
+# Supabase API - api.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name api.monacousa.org;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/api.monacousa.org.access.log;
+ error_log /var/log/nginx/api.monacousa.org.error.log;
+
+ # Client body size
+ client_max_body_size 50M;
+
+ # Rate limiting
+ limit_req zone=api_limit burst=50 nodelay;
+
+ location / {
+ proxy_pass http://api_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Longer timeout for realtime connections
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+ }
+}
+
+# Supabase Studio - studio.monacousa.org (optional)
+server {
+ listen 80;
+ listen [::]:80;
+ server_name studio.monacousa.org;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/studio.monacousa.org.access.log;
+ error_log /var/log/nginx/studio.monacousa.org.error.log;
+
+ location / {
+ proxy_pass http://studio_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/npm.log b/npm.log
new file mode 100644
index 0000000..e69de29
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..4146dc5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4514 @@
+{
+ "name": "monacousa-portal-2026",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "monacousa-portal-2026",
+ "version": "0.0.1",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.700.0",
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "@sveltejs/adapter-node": "^5.5.1",
+ "flag-icons": "^7.4.0",
+ "nodemailer": "^6.9.0"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.49.1",
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "bits-ui": "^2.15.4",
+ "clsx": "^2.1.1",
+ "lucide-svelte": "^0.562.0",
+ "svelte": "^5.45.6",
+ "svelte-check": "^4.3.4",
+ "tailwind-merge": "^3.4.0",
+ "tailwind-variants": "^3.2.2",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "vite": "^7.2.6"
+ }
+ },
+ "node_modules/@aws-crypto/crc32": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
+ "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/crc32c": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
+ "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
+ "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-js": "^5.2.0",
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-js": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/supports-web-crypto": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.222.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.974.0.tgz",
+ "integrity": "sha512-X+vpXNJ8cU8Iw1FtDgDHxo9z6RxlXfcTtpdGnKws4rk+tCYKSAor/DG6BRMzbh4E5xAA7DiU1Ny3BTrRRSt/Yg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha1-browser": "5.2.0",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-node": "^3.972.1",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.1",
+ "@aws-sdk/middleware-expect-continue": "^3.972.1",
+ "@aws-sdk/middleware-flexible-checksums": "^3.972.1",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-location-constraint": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.1",
+ "@aws-sdk/middleware-ssec": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/signature-v4-multi-region": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/eventstream-serde-browser": "^4.2.8",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.8",
+ "@smithy/eventstream-serde-node": "^4.2.8",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-blob-browser": "^4.2.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/hash-stream-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/md5-js": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-sso": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz",
+ "integrity": "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.0.tgz",
+ "integrity": "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/xml-builder": "^3.972.1",
+ "@smithy/core": "^3.21.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/crc64-nvme": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz",
+ "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.1.tgz",
+ "integrity": "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.1.tgz",
+ "integrity": "sha512-AeopObGW5lpWbDRZ+t4EAtS7wdfSrHPLeFts7jaBzgIaCCD7TL7jAyAB9Y5bCLOPF+17+GL54djCCsjePljUAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.1.tgz",
+ "integrity": "sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-login": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.1.tgz",
+ "integrity": "sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.1.tgz",
+ "integrity": "sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-ini": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.1.tgz",
+ "integrity": "sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.1.tgz",
+ "integrity": "sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/client-sso": "3.974.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/token-providers": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.1.tgz",
+ "integrity": "sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-bucket-endpoint": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.1.tgz",
+ "integrity": "sha512-YVvoitBdE8WOpHqIXvv49efT73F4bJ99XH2bi3Dn3mx7WngI4RwHwn/zF5i0q1Wdi5frGSCNF3vuh+pY817//w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-arn-parser": "^3.972.1",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-expect-continue": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.1.tgz",
+ "integrity": "sha512-6lfl2/J/kutzw/RLu1kjbahsz4vrGPysrdxWaw8fkjLYG+6M6AswocIAZFS/LgAVi/IWRwPTx9YC0/NH2wDrSw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-flexible-checksums": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.1.tgz",
+ "integrity": "sha512-kjVVREpqeUkYQsXr78AcsJbEUlxGH7+H6yS7zkjrnu6HyEVxbdSndkKX6VpKneFOihjCAhIXlk4wf3butDHkNQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@aws-crypto/crc32c": "5.2.0",
+ "@aws-crypto/util": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/crc64-nvme": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-host-header": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.1.tgz",
+ "integrity": "sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-location-constraint": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.1.tgz",
+ "integrity": "sha512-YisPaCbvBk9gY5aUI8jDMDKXsLZ9Fet0WYj1MviK8tZYMgxBIYHM6l3O/OHaAIujojZvamd9F3haYYYWp5/V3w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-logger": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.1.tgz",
+ "integrity": "sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-recursion-detection": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.1.tgz",
+ "integrity": "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws/lambda-invoke-store": "^0.2.2",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.1.tgz",
+ "integrity": "sha512-q/hK0ZNf/aafFRv2wIlDM3p+izi5cXwktVNvRvW646A0MvVZmT4/vwadv/jPA9AORFbnpyf/0luxiMz181f9yg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-arn-parser": "^3.972.1",
+ "@smithy/core": "^3.21.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-ssec": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.1.tgz",
+ "integrity": "sha512-fLtRTPd/MxJT2drJKft2GVGKm35PiNEeQ1Dvz1vc/WhhgAteYrp4f1SfSgjgLaYWGMExESJL4bt8Dxqp6tVsog==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-user-agent": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.1.tgz",
+ "integrity": "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@smithy/core": "^3.21.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.974.0.tgz",
+ "integrity": "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/region-config-resolver": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.1.tgz",
+ "integrity": "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.974.0.tgz",
+ "integrity": "sha512-tApmJb4XXBdNQzxTYIBq9aYj8vjJqiMPyeUF25wzvGjLQfXgvcv5sTR4yyzXBxRc8+O7quWDBgMJGtcNerapRQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/signature-v4-multi-region": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-format-url": "^3.972.1",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.972.0.tgz",
+ "integrity": "sha512-2udiRijmjpN81Pvajje4TsjbXDZNP6K9bYUanBYH8hXa/tZG5qfGCySD+TyX0sgDxCQmEDMg3LaQdfjNHBDEgQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "3.972.0",
+ "@aws-sdk/types": "3.972.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/core": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.972.0.tgz",
+ "integrity": "sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.972.0",
+ "@aws-sdk/xml-builder": "3.972.0",
+ "@smithy/core": "^3.20.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.0.tgz",
+ "integrity": "sha512-0bcKFXWx+NZ7tIlOo7KjQ+O2rydiHdIQahrq+fN6k9Osky29v17guy68urUKfhTobR6iY6KvxkroFWaFtTgS5w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "3.972.0",
+ "@aws-sdk/types": "3.972.0",
+ "@aws-sdk/util-arn-parser": "3.972.0",
+ "@smithy/core": "^3.20.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz",
+ "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.0.tgz",
+ "integrity": "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.0.tgz",
+ "integrity": "sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "fast-xml-parser": "5.2.5",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.974.0.tgz",
+ "integrity": "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.0.tgz",
+ "integrity": "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.1.tgz",
+ "integrity": "sha512-XnNit6H9PPHhqUXW/usjX6JeJ6Pm8ZNqivTjmNjgWHeOfVpblUc/MTic02UmCNR0jJLPjQ3mBKiMen0tnkNQjQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz",
+ "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.972.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz",
+ "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-format-url": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.1.tgz",
+ "integrity": "sha512-8wJ4/XOLU/RIYBHsXsIOTR04bNmalC8F2YPMyf3oL8YC750M3Rv5WGywW0Fo07HCv770KXJOzVq03Gyl68moFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-locate-window": {
+ "version": "3.965.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.3.tgz",
+ "integrity": "sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-browser": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.1.tgz",
+ "integrity": "sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-node": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.1.tgz",
+ "integrity": "sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "aws-crt": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "aws-crt": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.1.tgz",
+ "integrity": "sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "fast-xml-parser": "5.2.5",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
+ "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@internationalized/date": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
+ "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-commonjs": {
+ "version": "28.0.9",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz",
+ "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "commondir": "^1.0.1",
+ "estree-walker": "^2.0.2",
+ "fdir": "^6.2.0",
+ "is-reference": "1.2.1",
+ "magic-string": "^0.30.3",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=16.0.0 || 14 >= 14.17"
+ },
+ "peerDependencies": {
+ "rollup": "^2.68.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-json": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
+ "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
+ "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
+ "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
+ "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
+ "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
+ "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
+ "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
+ "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
+ "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
+ "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
+ "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
+ "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
+ "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
+ "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
+ "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
+ "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
+ "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
+ "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
+ "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
+ "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
+ "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
+ "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
+ "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+ "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@smithy/abort-controller": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
+ "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz",
+ "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader-native": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz",
+ "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/config-resolver": {
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz",
+ "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/core": {
+ "version": "3.21.1",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz",
+ "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz",
+ "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-codec": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz",
+ "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-browser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz",
+ "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-config-resolver": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz",
+ "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz",
+ "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-universal": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz",
+ "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-codec": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.3.9",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz",
+ "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-blob-browser": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz",
+ "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/chunked-blob-reader": "^5.2.0",
+ "@smithy/chunked-blob-reader-native": "^4.2.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz",
+ "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-stream-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz",
+ "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/invalid-dependency": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz",
+ "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/is-array-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
+ "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/md5-js": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz",
+ "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-content-length": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz",
+ "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-endpoint": {
+ "version": "4.4.11",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz",
+ "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-retry": {
+ "version": "4.4.27",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz",
+ "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-serde": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz",
+ "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-stack": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz",
+ "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-config-provider": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz",
+ "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz",
+ "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/property-provider": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz",
+ "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/protocol-http": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz",
+ "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-builder": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz",
+ "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz",
+ "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/service-error-classification": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz",
+ "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/shared-ini-file-loader": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz",
+ "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz",
+ "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/smithy-client": {
+ "version": "4.10.12",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz",
+ "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-endpoint": "^4.4.11",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
+ "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/url-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz",
+ "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/querystring-parser": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-base64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
+ "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-body-length-browser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
+ "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-body-length-node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz",
+ "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-buffer-from": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
+ "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-config-provider": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
+ "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-defaults-mode-browser": {
+ "version": "4.3.26",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz",
+ "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-defaults-mode-node": {
+ "version": "4.2.29",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz",
+ "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-endpoints": {
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz",
+ "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-hex-encoding": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
+ "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-middleware": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz",
+ "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-retry": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz",
+ "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-stream": {
+ "version": "4.5.10",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz",
+ "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-uri-escape": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
+ "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-utf8": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
+ "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-waiter": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz",
+ "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/uuid": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
+ "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/auth-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.91.0.tgz",
+ "integrity": "sha512-9ywvsKLsxTwv7fvN5fXzP3UfRreqrX2waylTBDu0lkmeHXa8WtSQS9e0WV9FBduiazYqQbgfBQXBNPRPsRgWOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.91.0.tgz",
+ "integrity": "sha512-WaakXOqLK1mLtBNFXp5o5T+LlI6KZuADSeXz+9ofPRG5OpVSvW148LVJB1DRZ16Phck1a0YqIUswOUgxCz6vMw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.91.0.tgz",
+ "integrity": "sha512-5S41zv2euNpGucvtM4Wy+xOmLznqt/XO+Lh823LOFEQ00ov7QJfvqb6VzIxufvzhooZpmGR0BxvMcJtWxCIFdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.91.0.tgz",
+ "integrity": "sha512-u2YuJFG35umw8DO9beC27L/jYXm3KhF+73WQwbynMpV0tXsFIA0DOGRM0NgRyy03hJIdO6mxTTwe8efW3yx3Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
+ "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.76.1"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.91.0.tgz",
+ "integrity": "sha512-CI7fsVIBQHfNObqU9kmyQ1GWr+Ug44y4rSpvxT4LdQB9tlhg1NTBov6z7Dlmt8d6lGi/8a9lf/epCDxyWI792g==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.91.0.tgz",
+ "integrity": "sha512-Rjb0QqkKrmXMVwUOdEqysPBZ0ZDZakeptTkUa6k2d8r3strBdbWVDqjOdkCjAmvvZMtXecBeyTyMEXD1Zzjfvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.91.0",
+ "@supabase/functions-js": "2.91.0",
+ "@supabase/postgrest-js": "2.91.0",
+ "@supabase/realtime-js": "2.91.0",
+ "@supabase/storage-js": "2.91.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-auto": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz",
+ "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-node": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.2.tgz",
+ "integrity": "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/plugin-commonjs": "^28.0.1",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.0",
+ "rollup": "^4.9.5"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.4.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.50.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz",
+ "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/kit/node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
+ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.0.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
+ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bits-ui": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz",
+ "integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.1",
+ "@floating-ui/dom": "^1.7.1",
+ "esm-env": "^1.1.2",
+ "runed": "^0.35.1",
+ "svelte-toolbelt": "^0.10.6",
+ "tabbable": "^6.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/huntabyte"
+ },
+ "peerDependencies": {
+ "@internationalized/date": "^3.8.1",
+ "svelte": "^5.33.0"
+ }
+ },
+ "node_modules/bowser": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
+ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
+ "license": "MIT"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
+ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
+ "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
+ "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/flag-icons": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
+ "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
+ "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "devOptional": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "license": "MIT"
+ },
+ "node_modules/lucide-svelte": {
+ "version": "0.562.0",
+ "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.562.0.tgz",
+ "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==",
+ "dev": true,
+ "license": "ISC",
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5.0.0-next.42"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
+ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.56.0",
+ "@rollup/rollup-android-arm64": "4.56.0",
+ "@rollup/rollup-darwin-arm64": "4.56.0",
+ "@rollup/rollup-darwin-x64": "4.56.0",
+ "@rollup/rollup-freebsd-arm64": "4.56.0",
+ "@rollup/rollup-freebsd-x64": "4.56.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.56.0",
+ "@rollup/rollup-linux-arm64-musl": "4.56.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.56.0",
+ "@rollup/rollup-linux-loong64-musl": "4.56.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.56.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.56.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-musl": "4.56.0",
+ "@rollup/rollup-openbsd-x64": "4.56.0",
+ "@rollup/rollup-openharmony-arm64": "4.56.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.56.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.56.0",
+ "@rollup/rollup-win32-x64-gnu": "4.56.0",
+ "@rollup/rollup-win32-x64-msvc": "4.56.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/runed": {
+ "version": "0.35.1",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
+ "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "esm-env": "^1.0.0",
+ "lz-string": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.21.0",
+ "svelte": "^5.7.0"
+ },
+ "peerDependenciesMeta": {
+ "@sveltejs/kit": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strnum": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.48.0",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz",
+ "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.1",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz",
+ "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte-toolbelt": {
+ "version": "0.10.6",
+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
+ "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte"
+ ],
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "runed": "^0.35.1",
+ "style-to-object": "^1.0.8"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.30.2"
+ }
+ },
+ "node_modules/svelte/node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwind-variants": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
+ "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.x",
+ "pnpm": ">=7.x"
+ },
+ "peerDependencies": {
+ "tailwind-merge": ">=3.0.0",
+ "tailwindcss": "*"
+ },
+ "peerDependenciesMeta": {
+ "tailwind-merge": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e4d1dd8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "monacousa-portal-2026",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.50.0",
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "bits-ui": "^2.15.4",
+ "clsx": "^2.1.1",
+ "lucide-svelte": "^0.562.0",
+ "svelte": "^5.47.0",
+ "svelte-check": "^4.3.4",
+ "tailwind-merge": "^3.4.0",
+ "tailwind-variants": "^3.2.2",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.971.0",
+ "@aws-sdk/s3-request-presigner": "^3.971.0",
+ "@internationalized/date": "^3.7.0",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "@sveltejs/adapter-node": "^5.5.1",
+ "flag-icons": "^7.4.0",
+ "libphonenumber-js": "^1.12.8",
+ "nodemailer": "^6.10.0"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..5f1fea1
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,7 @@
+// PostCSS config for monacousa-portal-2026
+// Tailwind CSS v4 is handled by @tailwindcss/vite plugin in vite.config.ts
+// This file exists to prevent Vite from picking up parent directory configs
+
+export default {
+ plugins: {}
+};
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..ec4b959
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,263 @@
+@import "tailwindcss";
+
+/* Monaco USA Custom Theme */
+@theme {
+ /* Monaco Red - Primary Brand Color */
+ --color-monaco-50: #fef2f2;
+ --color-monaco-100: #fee2e2;
+ --color-monaco-200: #fecaca;
+ --color-monaco-300: #fca5a5;
+ --color-monaco-400: #f87171;
+ --color-monaco-500: #ef4444;
+ --color-monaco-600: #dc2626;
+ --color-monaco-700: #b91c1c;
+ --color-monaco-800: #991b1b;
+ --color-monaco-900: #7f1d1d;
+ --color-monaco-950: #450a0a;
+
+ /* Glass effect shadows */
+ --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.1);
+ --shadow-glass-lg: 0 25px 50px rgba(0, 0, 0, 0.15);
+ --shadow-glass-xl: 0 35px 60px rgba(0, 0, 0, 0.2);
+
+ /* Backdrop blur values */
+ --blur-glass: 10px;
+ --blur-glass-lg: 20px;
+
+ /* Border radius */
+ --radius-glass: 16px;
+ --radius-glass-lg: 24px;
+
+ /* Animation durations */
+ --duration-fast: 150ms;
+ --duration-normal: 300ms;
+ --duration-slow: 500ms;
+
+ /* Font family */
+ --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+/* Base styles */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+ --primary: 0 84% 50%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 0 84% 50%;
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 0 84% 50%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 0 84% 50%;
+ }
+}
+
+@layer base {
+ * {
+ border-color: hsl(var(--border));
+ }
+
+ body {
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-family: var(--font-sans);
+ font-feature-settings: "rlig" 1, "calt" 1;
+ }
+}
+
+/* Glass-morphism utilities - in @layer components for proper ordering */
+@layer components {
+ .glass {
+ background: rgba(255, 255, 255, 0.7);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ box-shadow: var(--shadow-glass);
+ }
+
+ .glass-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: var(--shadow-glass);
+ }
+
+ .glass-card {
+ background: rgba(255, 255, 255, 0.97);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(226, 232, 240, 0.8);
+ box-shadow: var(--shadow-glass);
+ border-radius: var(--radius-glass);
+ }
+
+ .glass-card-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: var(--shadow-glass);
+ border-radius: var(--radius-glass);
+ }
+
+ .gradient-monaco {
+ background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-700) 100%);
+ }
+
+ .gradient-monaco-light {
+ background: linear-gradient(135deg, var(--color-monaco-50) 0%, var(--color-monaco-100) 100%);
+ }
+
+ .text-gradient-monaco {
+ background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-800) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+}
+
+/* Custom utilities for shadcn-svelte compatibility */
+@layer utilities {
+ /* Border color utility using CSS variable */
+ .border-border {
+ border-color: hsl(var(--border));
+ }
+
+ /* Background utilities using CSS variables */
+ .bg-background {
+ background-color: hsl(var(--background));
+ }
+
+ .bg-foreground {
+ background-color: hsl(var(--foreground));
+ }
+
+ .bg-card {
+ background-color: hsl(var(--card));
+ }
+
+ .bg-popover {
+ background-color: hsl(var(--popover));
+ }
+
+ .bg-primary {
+ background-color: hsl(var(--primary));
+ }
+
+ .bg-secondary {
+ background-color: hsl(var(--secondary));
+ }
+
+ .bg-muted {
+ background-color: hsl(var(--muted));
+ }
+
+ .bg-accent {
+ background-color: hsl(var(--accent));
+ }
+
+ .bg-destructive {
+ background-color: hsl(var(--destructive));
+ }
+
+ /* Text color utilities using CSS variables */
+ .text-foreground {
+ color: hsl(var(--foreground));
+ }
+
+ .text-card-foreground {
+ color: hsl(var(--card-foreground));
+ }
+
+ .text-popover-foreground {
+ color: hsl(var(--popover-foreground));
+ }
+
+ .text-primary-foreground {
+ color: hsl(var(--primary-foreground));
+ }
+
+ .text-secondary-foreground {
+ color: hsl(var(--secondary-foreground));
+ }
+
+ .text-muted-foreground {
+ color: hsl(var(--muted-foreground));
+ }
+
+ .text-accent-foreground {
+ color: hsl(var(--accent-foreground));
+ }
+
+ .text-destructive-foreground {
+ color: hsl(var(--destructive-foreground));
+ }
+
+ /* Ring utility */
+ .ring-ring {
+ --tw-ring-color: hsl(var(--ring));
+ }
+
+ /* Input border utility */
+ .border-input {
+ border-color: hsl(var(--input));
+ }
+
+ /* Scrollbar styling */
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: hsl(var(--muted-foreground) / 0.3);
+ border-radius: 3px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: hsl(var(--muted-foreground) / 0.5);
+ }
+}
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..df65d88
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,27 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
+import type { Database, MemberWithDues } from '$lib/types/database';
+
+declare global {
+ namespace App {
+ // interface Error {}
+ interface Locals {
+ supabase: SupabaseClient;
+ safeGetSession: () => Promise<{
+ session: Session | null;
+ user: User | null;
+ member: MemberWithDues | null;
+ }>;
+ }
+ interface PageData {
+ session: Session | null;
+ user: User | null;
+ member: MemberWithDues | null;
+ }
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..f273cc5
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 0000000..eb422c7
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,134 @@
+import pkg from '@supabase/ssr';
+const { createServerClient } = pkg;
+import { type Handle, redirect } from '@sveltejs/kit';
+import { sequence } from '@sveltejs/kit/hooks';
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import { env } from '$env/dynamic/private';
+import type { Database } from '$lib/types/database';
+
+// Use internal URL for server-side operations (Docker network), fallback to public URL
+const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL;
+
+/**
+ * Supabase authentication hook
+ * Sets up the Supabase client with cookie handling for SSR
+ */
+const supabaseHandle: Handle = async ({ event, resolve }) => {
+ event.locals.supabase = createServerClient(
+ SERVER_SUPABASE_URL,
+ PUBLIC_SUPABASE_ANON_KEY,
+ {
+ cookies: {
+ getAll: () => event.cookies.getAll(),
+ setAll: (cookiesToSet) => {
+ cookiesToSet.forEach(({ name, value, options }) => {
+ event.cookies.set(name, value, { ...options, path: '/' });
+ });
+ }
+ }
+ }
+ );
+
+ /**
+ * Safe session getter that validates the JWT
+ * Returns session, user, and member data
+ */
+ event.locals.safeGetSession = async () => {
+ const {
+ data: { session }
+ } = await event.locals.supabase.auth.getSession();
+
+ if (!session) {
+ return { session: null, user: null, member: null };
+ }
+
+ // Validate the session by getting the user
+ const {
+ data: { user },
+ error: userError
+ } = await event.locals.supabase.auth.getUser();
+
+ if (userError || !user) {
+ return { session: null, user: null, member: null };
+ }
+
+ // Fetch member profile with dues status
+ const { data: member } = await event.locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', user.id)
+ .single();
+
+ return { session, user, member };
+ };
+
+ return resolve(event, {
+ filterSerializedResponseHeaders(name) {
+ return name === 'content-range' || name === 'x-supabase-api-version';
+ }
+ });
+};
+
+/**
+ * Authorization hook
+ * Protects routes based on authentication and role requirements
+ */
+const authorizationHandle: Handle = async ({ event, resolve }) => {
+ const { session, member } = await event.locals.safeGetSession();
+ const path = event.url.pathname;
+
+ // API routes handle their own authentication
+ if (path.startsWith('/api/')) {
+ return resolve(event);
+ }
+
+ // Auth callback routes should always be accessible
+ if (path.startsWith('/auth/')) {
+ return resolve(event);
+ }
+
+ // Logout route should always be accessible
+ if (path === '/logout') {
+ return resolve(event);
+ }
+
+ // Protected routes - require authentication
+ const protectedPrefixes = ['/dashboard', '/profile', '/payments', '/documents', '/board', '/admin'];
+ const isProtectedRoute = protectedPrefixes.some((prefix) => path.startsWith(prefix));
+
+ if (isProtectedRoute && !session) {
+ throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
+ }
+
+ // Handle authenticated users without a member profile
+ // This can happen if member record creation failed or was deleted
+ if (isProtectedRoute && session && !member) {
+ console.error('Authenticated user has no member profile:', session.user?.id);
+ // Sign them out and redirect to login with an error
+ await event.locals.supabase.auth.signOut();
+ throw redirect(303, '/login?error=no_profile');
+ }
+
+ // Board routes - require board or admin role
+ if (path.startsWith('/board') && member) {
+ if (member.role !== 'board' && member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+ }
+
+ // Admin routes - require admin role
+ if (path.startsWith('/admin') && member) {
+ if (member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+ }
+
+ // Redirect authenticated users away from auth pages
+ if (session && (path === '/login' || path === '/signup')) {
+ throw redirect(303, '/dashboard');
+ }
+
+ return resolve(event);
+};
+
+export const handle: Handle = sequence(supabaseHandle, authorizationHandle);
diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+svelte-logo
\ No newline at end of file
diff --git a/src/lib/components/EmailVerificationBanner.svelte b/src/lib/components/EmailVerificationBanner.svelte
new file mode 100644
index 0000000..5175a26
--- /dev/null
+++ b/src/lib/components/EmailVerificationBanner.svelte
@@ -0,0 +1,92 @@
+
+
+{#if !dismissed}
+
+
+
+
+
+
+
+
+
+ Please verify your email address
+
+
+ Check {email} for verification link
+
+
+
+
+
+ {#if resendSuccess}
+
+
+ Email sent!
+
+ {:else}
+
+ {#if resending}
+
+ Sending...
+ {:else}
+
+ Resend
+ {/if}
+
+ {/if}
+ (dismissed = true)}
+ class="text-amber-600 hover:bg-amber-100 hover:text-amber-800 p-1.5"
+ aria-label="Dismiss"
+ >
+
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/auth/FormField.svelte b/src/lib/components/auth/FormField.svelte
new file mode 100644
index 0000000..7e95d1a
--- /dev/null
+++ b/src/lib/components/auth/FormField.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+ {label}
+ {#if required}
+ *
+ {/if}
+
+
+ {#if error}
+
{error}
+ {/if}
+
diff --git a/src/lib/components/auth/FormMessage.svelte b/src/lib/components/auth/FormMessage.svelte
new file mode 100644
index 0000000..a5a8390
--- /dev/null
+++ b/src/lib/components/auth/FormMessage.svelte
@@ -0,0 +1,29 @@
+
+
+{#if message}
+
+{/if}
diff --git a/src/lib/components/auth/LoadingSpinner.svelte b/src/lib/components/auth/LoadingSpinner.svelte
new file mode 100644
index 0000000..d9ca774
--- /dev/null
+++ b/src/lib/components/auth/LoadingSpinner.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/src/lib/components/auth/index.ts b/src/lib/components/auth/index.ts
new file mode 100644
index 0000000..1593171
--- /dev/null
+++ b/src/lib/components/auth/index.ts
@@ -0,0 +1,3 @@
+export { default as FormField } from './FormField.svelte';
+export { default as FormMessage } from './FormMessage.svelte';
+export { default as LoadingSpinner } from './LoadingSpinner.svelte';
diff --git a/src/lib/components/dashboard/DuesStatusCard.svelte b/src/lib/components/dashboard/DuesStatusCard.svelte
new file mode 100644
index 0000000..63dbc08
--- /dev/null
+++ b/src/lib/components/dashboard/DuesStatusCard.svelte
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ Dues Status
+
+
+
+
+
+
+
+
{duesInfo.label}
+
{duesInfo.description}
+
+
+
+
+
+
Annual Dues
+
+ {member.annual_dues ? `€${member.annual_dues.toFixed(2)}` : '€50.00'}
+
+
+
+
Membership Type
+
{member.membership_type_name || 'Regular'}
+
+
+
Last Payment
+
{formatDate(member.last_payment_date)}
+
+
+
Next Due Date
+
{formatDate(member.current_due_date)}
+
+
+
+
+
+
+ View Payment Details
+
+
+
+
diff --git a/src/lib/components/dashboard/QuickActionsCard.svelte b/src/lib/components/dashboard/QuickActionsCard.svelte
new file mode 100644
index 0000000..93a394f
--- /dev/null
+++ b/src/lib/components/dashboard/QuickActionsCard.svelte
@@ -0,0 +1,97 @@
+
+
+
diff --git a/src/lib/components/dashboard/StatsCard.svelte b/src/lib/components/dashboard/StatsCard.svelte
new file mode 100644
index 0000000..26e0892
--- /dev/null
+++ b/src/lib/components/dashboard/StatsCard.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
{title}
+
{value}
+ {#if description}
+
{description}
+ {/if}
+ {#if trend}
+
+ = 0 ? 'text-green-600' : 'text-red-600'}>
+ {trend.value >= 0 ? '+' : ''}{trend.value}%
+
+ {trend.label}
+
+ {/if}
+
+
+
+
+
+
diff --git a/src/lib/components/dashboard/UpcomingEventsCard.svelte b/src/lib/components/dashboard/UpcomingEventsCard.svelte
new file mode 100644
index 0000000..b5446c2
--- /dev/null
+++ b/src/lib/components/dashboard/UpcomingEventsCard.svelte
@@ -0,0 +1,107 @@
+
+
+
diff --git a/src/lib/components/dashboard/WelcomeCard.svelte b/src/lib/components/dashboard/WelcomeCard.svelte
new file mode 100644
index 0000000..36f7d53
--- /dev/null
+++ b/src/lib/components/dashboard/WelcomeCard.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name[0]}{member.last_name[0]}
+
+ {/if}
+
+
+
{getGreeting()},
+
+ {member.first_name}
+ {member.last_name}
+
+
+ {member.member_id}
+ |
+
+ {member.status_display_name || 'Pending'}
+
+
+
+
+
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
diff --git a/src/lib/components/dashboard/index.ts b/src/lib/components/dashboard/index.ts
new file mode 100644
index 0000000..49c1a5d
--- /dev/null
+++ b/src/lib/components/dashboard/index.ts
@@ -0,0 +1,5 @@
+export { default as WelcomeCard } from './WelcomeCard.svelte';
+export { default as DuesStatusCard } from './DuesStatusCard.svelte';
+export { default as UpcomingEventsCard } from './UpcomingEventsCard.svelte';
+export { default as QuickActionsCard } from './QuickActionsCard.svelte';
+export { default as StatsCard } from './StatsCard.svelte';
diff --git a/src/lib/components/documents/CreateFolderModal.svelte b/src/lib/components/documents/CreateFolderModal.svelte
new file mode 100644
index 0000000..5a3658c
--- /dev/null
+++ b/src/lib/components/documents/CreateFolderModal.svelte
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create New Folder
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/documents/DocumentPreviewModal.svelte b/src/lib/components/documents/DocumentPreviewModal.svelte
new file mode 100644
index 0000000..d2ffbcb
--- /dev/null
+++ b/src/lib/components/documents/DocumentPreviewModal.svelte
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if isPdf}
+
+ {:else if isImage}
+
+ {:else}
+
+ {/if}
+
+
{document.title}
+
{document.file_name} · {formatFileSize(document.file_size)}
+
+
+
+
+
+ {#if isImage}
+
+
+
+
+ {zoom}%
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+ Download
+
+
+
+
+
+
+
+
+
+
+
+ {#if isLoading && !isImage && !isPdf}
+
+ {:else if loadError}
+
+
+
Unable to load preview
+
+
+ Download File
+
+
+ {:else if isPdf}
+
+
+ {:else if isImage}
+
+
+
isLoading = false}
+ onerror={() => { loadError = true; isLoading = false; }}
+ />
+
+ {:else if isText && textContent !== null}
+
+
+ {:else if isOffice}
+
+
+
+
Office documents cannot be previewed directly
+
Download the file to view it in Microsoft Office or compatible application
+
+
+ Download {document.file_name}
+
+
+ {:else}
+
+
+
+
Preview not available for this file type
+
+
+ Download File
+
+
+ {/if}
+
+
+
diff --git a/src/lib/components/documents/FolderBreadcrumbs.svelte b/src/lib/components/documents/FolderBreadcrumbs.svelte
new file mode 100644
index 0000000..0320925
--- /dev/null
+++ b/src/lib/components/documents/FolderBreadcrumbs.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#each breadcrumbs as crumb, index}
+ {#if index > 0}
+
+ {/if}
+
+ {#if index === breadcrumbs.length - 1}
+
+
+ {#if index === 0}
+
+ {:else}
+
+ {/if}
+ {crumb.name}
+
+ {:else}
+
+ onNavigate(crumb.id)}
+ class="flex items-center gap-1.5 rounded-md px-2 py-1 text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
+ >
+ {#if index === 0}
+
+ {:else}
+
+ {/if}
+ {crumb.name}
+
+ {/if}
+ {/each}
+
diff --git a/src/lib/components/documents/FolderItem.svelte b/src/lib/components/documents/FolderItem.svelte
new file mode 100644
index 0000000..8cbd7ae
--- /dev/null
+++ b/src/lib/components/documents/FolderItem.svelte
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
{folder.name}
+
+
+ {folder.visibility}
+
+ {#if folder.creator}
+ ·
+ {folder.creator.first_name} {folder.creator.last_name}
+ {/if}
+
+
+
+
+ {#if canEdit || canDelete}
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {#if canEdit && onRename}
+ onRename(folder)}
+ >
+
+ Rename
+
+ {/if}
+ {#if canDelete && onDelete}
+ onDelete(folder)}
+ >
+
+ Delete
+
+ {/if}
+
+
+
+
+ {/if}
+
diff --git a/src/lib/components/documents/index.ts b/src/lib/components/documents/index.ts
new file mode 100644
index 0000000..7da6c72
--- /dev/null
+++ b/src/lib/components/documents/index.ts
@@ -0,0 +1,4 @@
+export { default as DocumentPreviewModal } from './DocumentPreviewModal.svelte';
+export { default as FolderItem } from './FolderItem.svelte';
+export { default as FolderBreadcrumbs } from './FolderBreadcrumbs.svelte';
+export { default as CreateFolderModal } from './CreateFolderModal.svelte';
diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte
new file mode 100644
index 0000000..3b01046
--- /dev/null
+++ b/src/lib/components/layout/Header.svelte
@@ -0,0 +1,152 @@
+
+
+
+
+
diff --git a/src/lib/components/layout/MobileMenu.svelte b/src/lib/components/layout/MobileMenu.svelte
new file mode 100644
index 0000000..9cf0a41
--- /dev/null
+++ b/src/lib/components/layout/MobileMenu.svelte
@@ -0,0 +1,203 @@
+
+
+{#if open}
+
+
+
+
+
+
+
+
+ Menu
+
+
+
+
+
+
+
+
+
+
+
+ {#if isBoard}
+
+
+ Board
+
+
+ {/if}
+
+
+ {#if isAdmin}
+
+
+ Admin
+
+
+ {/if}
+
+
+
+
+ {#if member}
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name[0]}{member.last_name[0]}
+
+ {/if}
+
+
+ {member.first_name}
+ {member.last_name}
+
+
{member.member_id}
+
+
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/layout/MobileNav.svelte b/src/lib/components/layout/MobileNav.svelte
new file mode 100644
index 0000000..a8fbf07
--- /dev/null
+++ b/src/lib/components/layout/MobileNav.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+ {#each navItems as item}
+ {@const Icon = item.icon}
+
+
+ {item.label}
+
+ {/each}
+
+
+ More
+
+
+
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
new file mode 100644
index 0000000..3189fcf
--- /dev/null
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -0,0 +1,245 @@
+
+
+
diff --git a/src/lib/components/layout/index.ts b/src/lib/components/layout/index.ts
new file mode 100644
index 0000000..1bdcf7c
--- /dev/null
+++ b/src/lib/components/layout/index.ts
@@ -0,0 +1,4 @@
+export { default as Sidebar } from './Sidebar.svelte';
+export { default as Header } from './Header.svelte';
+export { default as MobileNav } from './MobileNav.svelte';
+export { default as MobileMenu } from './MobileMenu.svelte';
diff --git a/src/lib/components/ui/AddToCalendarButton.svelte b/src/lib/components/ui/AddToCalendarButton.svelte
new file mode 100644
index 0000000..f625291
--- /dev/null
+++ b/src/lib/components/ui/AddToCalendarButton.svelte
@@ -0,0 +1,171 @@
+
+
+
+
(isOpen = !isOpen)}
+ class="gap-2"
+ >
+
+ Add to Calendar
+
+
+
+ {#if isOpen}
+
+ {/if}
+
diff --git a/src/lib/components/ui/CountryFlag.svelte b/src/lib/components/ui/CountryFlag.svelte
new file mode 100644
index 0000000..42d12cf
--- /dev/null
+++ b/src/lib/components/ui/CountryFlag.svelte
@@ -0,0 +1,40 @@
+
+
+
diff --git a/src/lib/components/ui/CountrySelect.svelte b/src/lib/components/ui/CountrySelect.svelte
new file mode 100644
index 0000000..704a06f
--- /dev/null
+++ b/src/lib/components/ui/CountrySelect.svelte
@@ -0,0 +1,173 @@
+
+
+
+
({ value: c.code, label: c.name }))}
+ bind:value
+ onValueChange={(v) => v && handleSelect(v)}
+ >
+
+
+ {#if selectedCountry}
+
+
+ {selectedCountry.name}
+
+ {:else}
+ {placeholder}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {
+ searchValue = e.currentTarget.value;
+ }}
+ />
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = value === country.code}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {#if selected}
+
+ {/if}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/DatePicker.svelte b/src/lib/components/ui/DatePicker.svelte
new file mode 100644
index 0000000..91dcc9d
--- /dev/null
+++ b/src/lib/components/ui/DatePicker.svelte
@@ -0,0 +1,190 @@
+
+
+
+
+ {#snippet children({ segments })}
+
+ {#each segments as { part, value: segValue }}
+ {#if part === 'literal'}
+ {segValue}
+ {:else}
+
+ {segValue}
+
+ {/if}
+ {/each}
+
+
+
+
+ {/snippet}
+
+
+
+
+ {#snippet children({ months, weekdays })}
+
+
+
+
+
+
+
+
+
+
+ {#each months as month}
+
+
+
+ {#each weekdays as day}
+
+ {day.slice(0, 2)}
+
+ {/each}
+
+
+
+ {#each month.weeks as weekDates}
+
+ {#each weekDates as date}
+
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+ {/snippet}
+
+
+
+
+
+{#if name}
+
+{/if}
diff --git a/src/lib/components/ui/NationalitySelect.svelte b/src/lib/components/ui/NationalitySelect.svelte
new file mode 100644
index 0000000..c8f542c
--- /dev/null
+++ b/src/lib/components/ui/NationalitySelect.svelte
@@ -0,0 +1,198 @@
+
+
+
+
+ {#if value.length > 0}
+
+ {#each value as code}
+ {@const country = getCountry(code)}
+ {#if country}
+
+
+ {country.name}
+ removeSelection(code)}
+ class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-monaco-200"
+ {disabled}
+ >
+
+
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
onValueChange?.(v)}
+ >
+
+
+
+ 0 ? 'Add more...' : placeholder}
+ {disabled}
+ oninput={(e) => {
+ searchValue = e.currentTarget.value;
+ if (!open) open = true;
+ }}
+ />
+
+
+
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = value.includes(country.code)}
+ {@const isDisabled = !isSelected && maxSelections > 0 && value.length >= maxSelections}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {country.code}
+ {#if selected}
+
+ {/if}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/PhoneInput.svelte b/src/lib/components/ui/PhoneInput.svelte
new file mode 100644
index 0000000..3e7e9a0
--- /dev/null
+++ b/src/lib/components/ui/PhoneInput.svelte
@@ -0,0 +1,260 @@
+
+
+
+
+
({ value: c.code, label: c.name }))}
+ bind:value={countryCode}
+ onValueChange={(v) => v && handleCountryChange(v)}
+ >
+
+
+
+ {selectedCountry.dialCode}
+
+
+
+
+
+
+
+
+
+
+ {
+ searchValue = e.currentTarget.value;
+ }}
+ />
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = countryCode === country.code}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {country.dialCode}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..a6711e0
--- /dev/null
+++ b/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..6086c50
--- /dev/null
+++ b/src/lib/components/ui/button/index.ts
@@ -0,0 +1,15 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants
+} from './button.svelte';
+
+export {
+ Root,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+ Root as Button
+};
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..fba5c6b
--- /dev/null
+++ b/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..2da58ab
--- /dev/null
+++ b/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..739f801
--- /dev/null
+++ b/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..80801c6
--- /dev/null
+++ b/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..9b4e997
--- /dev/null
+++ b/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..29e1c6f
--- /dev/null
+++ b/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..2f3ee7a
--- /dev/null
+++ b/src/lib/components/ui/card/index.ts
@@ -0,0 +1,21 @@
+import Root from './card.svelte';
+import Content from './card-content.svelte';
+import Description from './card-description.svelte';
+import Footer from './card-footer.svelte';
+import Header from './card-header.svelte';
+import Title from './card-title.svelte';
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle
+};
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts
new file mode 100644
index 0000000..41c3320
--- /dev/null
+++ b/src/lib/components/ui/index.ts
@@ -0,0 +1,19 @@
+// UI Components - shadcn-svelte style
+export { Button, buttonVariants, type ButtonProps, type ButtonSize, type ButtonVariant } from './button';
+export {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle
+} from './card';
+export { Input } from './input';
+export { Label } from './label';
+
+// Custom components
+export { default as DatePicker } from './DatePicker.svelte';
+export { default as NationalitySelect } from './NationalitySelect.svelte';
+export { default as CountryFlag } from './CountryFlag.svelte';
+export { default as PhoneInput } from './PhoneInput.svelte';
+export { default as CountrySelect } from './CountrySelect.svelte';
diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..ba5ce62
--- /dev/null
+++ b/src/lib/components/ui/input/index.ts
@@ -0,0 +1,3 @@
+import Root from './input.svelte';
+
+export { Root, Root as Input };
diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..640c347
--- /dev/null
+++ b/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..af72692
--- /dev/null
+++ b/src/lib/components/ui/label/index.ts
@@ -0,0 +1,3 @@
+import Root from './label.svelte';
+
+export { Root, Root as Label };
diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..9442ac9
--- /dev/null
+++ b/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/index.ts b/src/lib/index.ts
new file mode 100644
index 0000000..68d31ef
--- /dev/null
+++ b/src/lib/index.ts
@@ -0,0 +1,3 @@
+// Monaco USA Portal 2026 - Library exports
+export * from './utils';
+
diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts
new file mode 100644
index 0000000..9ebea9a
--- /dev/null
+++ b/src/lib/server/audit.ts
@@ -0,0 +1,233 @@
+import { supabaseAdmin } from './supabase';
+
+export type AuditAction =
+ | 'member.create'
+ | 'member.update'
+ | 'member.delete'
+ | 'member.role_change'
+ | 'member.status_change'
+ | 'member.invite'
+ | 'event.create'
+ | 'event.update'
+ | 'event.delete'
+ | 'event.cancel'
+ | 'rsvp.create'
+ | 'rsvp.update'
+ | 'rsvp.cancel'
+ | 'rsvp.waitlist_promote'
+ | 'payment.record'
+ | 'payment.delete'
+ | 'document.upload'
+ | 'document.delete'
+ | 'document.visibility_change'
+ | 'settings.update'
+ | 'email.send'
+ | 'auth.login'
+ | 'auth.logout'
+ | 'auth.password_reset';
+
+export interface AuditLogEntry {
+ userId?: string;
+ userEmail?: string;
+ action: AuditAction;
+ resourceType?: string;
+ resourceId?: string;
+ details?: Record;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+/**
+ * Log an audit event to the database
+ */
+export async function logAudit(entry: AuditLogEntry): Promise<{ success: boolean; error?: string }> {
+ try {
+ const { error } = await supabaseAdmin.from('audit_logs').insert({
+ user_id: entry.userId || null,
+ user_email: entry.userEmail || null,
+ action: entry.action,
+ resource_type: entry.resourceType || null,
+ resource_id: entry.resourceId || null,
+ details: entry.details || {},
+ ip_address: entry.ipAddress || null,
+ user_agent: entry.userAgent || null
+ });
+
+ if (error) {
+ console.error('Audit log error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Audit log exception:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Log a member-related action
+ */
+export async function logMemberAction(
+ action: 'create' | 'update' | 'delete' | 'role_change' | 'status_change' | 'invite',
+ performedBy: { id: string; email: string },
+ targetMember: { id: string; email?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `member.${action}` as AuditAction,
+ resourceType: 'member',
+ resourceId: targetMember.id,
+ details: {
+ target_email: targetMember.email,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log an event-related action
+ */
+export async function logEventAction(
+ action: 'create' | 'update' | 'delete' | 'cancel',
+ performedBy: { id: string; email: string },
+ event: { id: string; title?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `event.${action}` as AuditAction,
+ resourceType: 'event',
+ resourceId: event.id,
+ details: {
+ event_title: event.title,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log a payment-related action
+ */
+export async function logPaymentAction(
+ action: 'record' | 'delete',
+ performedBy: { id: string; email: string },
+ payment: { id?: string; memberId: string; amount?: number },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `payment.${action}` as AuditAction,
+ resourceType: 'payment',
+ resourceId: payment.id,
+ details: {
+ member_id: payment.memberId,
+ amount: payment.amount,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log a document-related action
+ */
+export async function logDocumentAction(
+ action: 'upload' | 'delete' | 'visibility_change',
+ performedBy: { id: string; email: string },
+ document: { id: string; title?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `document.${action}` as AuditAction,
+ resourceType: 'document',
+ resourceId: document.id,
+ details: {
+ document_title: document.title,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log settings update
+ */
+export async function logSettingsUpdate(
+ performedBy: { id: string; email: string },
+ category: string,
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: 'settings.update',
+ resourceType: 'settings',
+ resourceId: category,
+ details,
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Get recent audit logs
+ */
+export async function getRecentAuditLogs(
+ limit: number = 50,
+ filters?: {
+ action?: string;
+ resourceType?: string;
+ userId?: string;
+ startDate?: string;
+ endDate?: string;
+ }
+): Promise<{ logs: any[]; error: string | null }> {
+ let query = supabaseAdmin
+ .from('audit_logs')
+ .select('*')
+ .order('created_at', { ascending: false })
+ .limit(limit);
+
+ if (filters?.action) {
+ query = query.eq('action', filters.action);
+ }
+ if (filters?.resourceType) {
+ query = query.eq('resource_type', filters.resourceType);
+ }
+ if (filters?.userId) {
+ query = query.eq('user_id', filters.userId);
+ }
+ if (filters?.startDate) {
+ query = query.gte('created_at', filters.startDate);
+ }
+ if (filters?.endDate) {
+ query = query.lte('created_at', filters.endDate);
+ }
+
+ const { data, error } = await query;
+
+ if (error) {
+ return { logs: [], error: error.message };
+ }
+
+ return { logs: data || [], error: null };
+}
diff --git a/src/lib/server/dues.ts b/src/lib/server/dues.ts
new file mode 100644
index 0000000..1c0ba73
--- /dev/null
+++ b/src/lib/server/dues.ts
@@ -0,0 +1,881 @@
+/**
+ * Dues Management Service
+ * Handles dues reminders, bulk operations, and analytics
+ */
+
+import { supabaseAdmin } from './supabase';
+import { sendTemplatedEmail } from './email';
+import type { MemberWithDues } from '$lib/types/database';
+
+// ============================================
+// TYPES
+// ============================================
+
+export type ReminderType = 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+
+// Onboarding reminder types (for new members with payment_deadline)
+export type OnboardingReminderType = 'onboarding_reminder_7' | 'onboarding_reminder_1' | 'onboarding_expired';
+
+export interface DuesSettings {
+ reminder_days_before: number[];
+ grace_period_days: number;
+ auto_inactive_enabled: boolean;
+ payment_iban: string;
+ payment_account_holder: string;
+ payment_bank_name: string;
+}
+
+export interface DuesReminderResult {
+ sent: number;
+ skipped: number;
+ errors: string[];
+ members: Array<{ id: string; name: string; email: string; status: 'sent' | 'skipped' | 'error'; error?: string }>;
+}
+
+export interface DuesAnalytics {
+ totalMembers: number;
+ current: number;
+ dueSoon: number;
+ overdue: number;
+ neverPaid: number;
+ totalCollectedThisMonth: number;
+ totalCollectedThisYear: number;
+ totalOutstanding: number;
+ paymentsByMonth: Array<{ month: string; amount: number; count: number }>;
+ remindersSentThisMonth: number;
+ statusBreakdown: Array<{ status: string; count: number; percentage: number }>;
+}
+
+// ============================================
+// SETTINGS
+// ============================================
+
+/**
+ * Get dues-related settings from the database
+ */
+export async function getDuesSettings(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'dues');
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ config[s.setting_key] = s.setting_value;
+ }
+
+ return {
+ reminder_days_before: Array.isArray(config.reminder_days_before)
+ ? config.reminder_days_before
+ : [30, 7, 1],
+ grace_period_days: typeof config.grace_period_days === 'number' ? config.grace_period_days : 30,
+ auto_inactive_enabled:
+ typeof config.auto_inactive_enabled === 'boolean' ? config.auto_inactive_enabled : true,
+ payment_iban: config.payment_iban || '',
+ payment_account_holder: config.payment_account_holder || '',
+ payment_bank_name: config.payment_bank_name || ''
+ };
+}
+
+// ============================================
+// MEMBER QUERIES
+// ============================================
+
+/**
+ * Get members who need a specific type of reminder
+ * Excludes members who have already received this reminder for their current due date
+ */
+export async function getMembersNeedingReminder(reminderType: ReminderType): Promise {
+ const settings = await getDuesSettings();
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Get all members with dues info
+ const { data: members, error } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .not('email', 'is', null);
+
+ if (error || !members) {
+ console.error('Error fetching members:', error);
+ return [];
+ }
+
+ // Filter based on reminder type
+ let filteredMembers: MemberWithDues[] = [];
+
+ if (reminderType.startsWith('due_soon_')) {
+ const daysMatch = reminderType.match(/due_soon_(\d+)/);
+ if (!daysMatch) return [];
+ const daysBefore = parseInt(daysMatch[1]);
+
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date || m.dues_status === 'never_paid') return false;
+ const dueDate = new Date(m.current_due_date);
+ const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ // Member is due within the specified window (e.g., 30 days means dues_until <= 30)
+ return daysUntil > 0 && daysUntil <= daysBefore;
+ });
+ } else if (reminderType === 'overdue') {
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date) return false;
+ const daysOverdue = m.days_overdue || 0;
+ // Overdue but still within grace period
+ return m.dues_status === 'overdue' && daysOverdue <= settings.grace_period_days;
+ });
+ } else if (reminderType === 'grace_period') {
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date) return false;
+ const daysOverdue = m.days_overdue || 0;
+ // In final week of grace period
+ const graceDaysRemaining = settings.grace_period_days - daysOverdue;
+ return m.dues_status === 'overdue' && graceDaysRemaining > 0 && graceDaysRemaining <= 7;
+ });
+ }
+
+ // Exclude members who already received this reminder for their current due period
+ if (filteredMembers.length > 0) {
+ const memberIds = filteredMembers.map((m) => m.id);
+
+ // Get reminders already sent
+ const { data: existingReminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id, due_date')
+ .eq('reminder_type', reminderType)
+ .in('member_id', memberIds);
+
+ if (existingReminders && existingReminders.length > 0) {
+ const sentSet = new Set(
+ existingReminders.map((r) => `${r.member_id}-${r.due_date}`)
+ );
+
+ filteredMembers = filteredMembers.filter((m) => {
+ const key = `${m.id}-${m.current_due_date}`;
+ return !sentSet.has(key);
+ });
+ }
+ }
+
+ return filteredMembers;
+}
+
+/**
+ * Get overdue members who have exceeded the grace period and should be marked inactive
+ */
+export async function getMembersForInactivation(): Promise {
+ const settings = await getDuesSettings();
+
+ if (!settings.auto_inactive_enabled) {
+ return [];
+ }
+
+ const { data: members } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .eq('dues_status', 'overdue')
+ .not('status_name', 'eq', 'inactive');
+
+ if (!members) return [];
+
+ // Filter to those past grace period
+ return members.filter((m) => {
+ const daysOverdue = m.days_overdue || 0;
+ return daysOverdue > settings.grace_period_days;
+ });
+}
+
+// ============================================
+// REMINDER SENDING
+// ============================================
+
+/**
+ * Send a dues reminder to a specific member
+ */
+export async function sendDuesReminder(
+ member: MemberWithDues,
+ reminderType: ReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string; emailLogId?: string }> {
+ const settings = await getDuesSettings();
+
+ // Determine template key based on reminder type
+ const templateKey =
+ reminderType === 'overdue'
+ ? 'dues_overdue'
+ : reminderType === 'grace_period'
+ ? 'dues_grace_warning'
+ : reminderType === 'inactive_notice'
+ ? 'dues_inactive_notice'
+ : `dues_reminder_${reminderType.replace('due_soon_', '')}`;
+
+ // Calculate variables
+ const dueDate = member.current_due_date
+ ? new Date(member.current_due_date).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ : 'N/A';
+
+ const daysOverdue = member.days_overdue || 0;
+ const graceDaysRemaining = Math.max(0, settings.grace_period_days - daysOverdue);
+ const graceEndDate = member.current_due_date
+ ? new Date(
+ new Date(member.current_due_date).getTime() +
+ settings.grace_period_days * 24 * 60 * 60 * 1000
+ ).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ : 'N/A';
+
+ const variables: Record = {
+ first_name: member.first_name,
+ last_name: member.last_name,
+ member_id: member.member_id,
+ due_date: dueDate,
+ amount: `€${(member.annual_dues || 50).toFixed(2)}`,
+ days_overdue: daysOverdue.toString(),
+ grace_days_remaining: graceDaysRemaining.toString(),
+ grace_end_date: graceEndDate,
+ account_holder: settings.payment_account_holder,
+ bank_name: settings.payment_bank_name,
+ iban: settings.payment_iban,
+ portal_url: `${baseUrl}/payments`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail(templateKey, member.email, variables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: reminderType,
+ due_date: member.current_due_date || new Date().toISOString().split('T')[0]
+ });
+
+ if (logError) {
+ console.error('Error logging reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send bulk reminders of a specific type
+ */
+export async function sendBulkReminders(
+ reminderType: ReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const members = await getMembersNeedingReminder(reminderType);
+
+ const result: DuesReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ members: []
+ };
+
+ for (const member of members) {
+ try {
+ const sendResult = await sendDuesReminder(member, reminderType, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${member.email}: ${sendResult.error}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${member.email}: ${errorMessage}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// GRACE PERIOD & INACTIVATION
+// ============================================
+
+/**
+ * Process members who have exceeded grace period and mark them inactive
+ */
+export async function processGracePeriodExpirations(
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> {
+ const settings = await getDuesSettings();
+
+ if (!settings.auto_inactive_enabled) {
+ return { processed: 0, members: [] };
+ }
+
+ const members = await getMembersForInactivation();
+ const processed: Array<{ id: string; name: string; email: string }> = [];
+
+ // Get inactive status ID
+ const { data: inactiveStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'inactive')
+ .single();
+
+ if (!inactiveStatus) {
+ console.error('Inactive status not found');
+ return { processed: 0, members: [] };
+ }
+
+ for (const member of members) {
+ // Update member status to inactive
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({ membership_status_id: inactiveStatus.id })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error(`Error updating member ${member.id}:`, updateError);
+ continue;
+ }
+
+ // Send inactive notice
+ await sendDuesReminder(member, 'inactive_notice', baseUrl);
+
+ // Log the reminder
+ await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: 'inactive_notice',
+ due_date: member.current_due_date || new Date().toISOString().split('T')[0]
+ });
+
+ processed.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email
+ });
+ }
+
+ return { processed: processed.length, members: processed };
+}
+
+// ============================================
+// ANALYTICS
+// ============================================
+
+/**
+ * Get comprehensive dues analytics
+ */
+export async function getDuesAnalytics(): Promise {
+ const now = new Date();
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+ const startOfYear = new Date(now.getFullYear(), 0, 1);
+
+ // Get members with dues
+ const { data: members } = await supabaseAdmin.from('members_with_dues').select('*');
+
+ const allMembers = members || [];
+ const totalMembers = allMembers.length;
+ const current = allMembers.filter((m) => m.dues_status === 'current').length;
+ const dueSoon = allMembers.filter((m) => m.dues_status === 'due_soon').length;
+ const overdue = allMembers.filter((m) => m.dues_status === 'overdue').length;
+ const neverPaid = allMembers.filter((m) => m.dues_status === 'never_paid').length;
+
+ // Calculate total outstanding (overdue + due_soon + never_paid)
+ const totalOutstanding = allMembers
+ .filter((m) => m.dues_status !== 'current')
+ .reduce((sum, m) => sum + (m.annual_dues || 0), 0);
+
+ // Get payments this month
+ const { data: monthPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount')
+ .gte('payment_date', startOfMonth.toISOString().split('T')[0]);
+
+ const totalCollectedThisMonth = (monthPayments || []).reduce((sum, p) => sum + p.amount, 0);
+
+ // Get payments this year
+ const { data: yearPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount')
+ .gte('payment_date', startOfYear.toISOString().split('T')[0]);
+
+ const totalCollectedThisYear = (yearPayments || []).reduce((sum, p) => sum + p.amount, 0);
+
+ // Get payments by month (last 12 months)
+ const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
+ const { data: allPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount, payment_date')
+ .gte('payment_date', twelveMonthsAgo.toISOString().split('T')[0])
+ .order('payment_date', { ascending: true });
+
+ const paymentsByMonth: Array<{ month: string; amount: number; count: number }> = [];
+ const monthMap = new Map();
+
+ for (const payment of allPayments || []) {
+ const date = new Date(payment.payment_date);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+
+ const existing = monthMap.get(monthKey) || { amount: 0, count: 0 };
+ existing.amount += payment.amount;
+ existing.count += 1;
+ monthMap.set(monthKey, existing);
+ }
+
+ // Fill in missing months
+ for (let i = 0; i < 12; i++) {
+ const date = new Date(now.getFullYear(), now.getMonth() - 11 + i, 1);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+ const data = monthMap.get(monthKey) || { amount: 0, count: 0 };
+ paymentsByMonth.push({
+ month: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
+ amount: data.amount,
+ count: data.count
+ });
+ }
+
+ // Get reminders sent this month
+ const { count: remindersSentThisMonth } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('*', { count: 'exact', head: true })
+ .gte('sent_at', startOfMonth.toISOString());
+
+ // Status breakdown with percentages
+ const statusBreakdown = [
+ { status: 'current', count: current, percentage: totalMembers > 0 ? (current / totalMembers) * 100 : 0 },
+ { status: 'due_soon', count: dueSoon, percentage: totalMembers > 0 ? (dueSoon / totalMembers) * 100 : 0 },
+ { status: 'overdue', count: overdue, percentage: totalMembers > 0 ? (overdue / totalMembers) * 100 : 0 },
+ { status: 'never_paid', count: neverPaid, percentage: totalMembers > 0 ? (neverPaid / totalMembers) * 100 : 0 }
+ ];
+
+ return {
+ totalMembers,
+ current,
+ dueSoon,
+ overdue,
+ neverPaid,
+ totalCollectedThisMonth,
+ totalCollectedThisYear,
+ totalOutstanding,
+ paymentsByMonth,
+ remindersSentThisMonth: remindersSentThisMonth || 0,
+ statusBreakdown
+ };
+}
+
+/**
+ * Get detailed dues report data for CSV export
+ */
+export async function getDuesReportData(): Promise<{
+ members: Array<{
+ member_id: string;
+ name: string;
+ email: string;
+ membership_type: string;
+ status: string;
+ dues_status: string;
+ annual_dues: number;
+ last_payment_date: string | null;
+ current_due_date: string | null;
+ days_overdue: number | null;
+ }>;
+ payments: Array<{
+ member_id: string;
+ member_name: string;
+ amount: number;
+ payment_date: string;
+ payment_method: string;
+ reference: string | null;
+ recorded_by: string | null;
+ }>;
+}> {
+ // Get members with dues
+ const { data: members } = await supabaseAdmin.from('members_with_dues').select('*');
+
+ const memberReport = (members || []).map((m) => ({
+ member_id: m.member_id,
+ name: `${m.first_name} ${m.last_name}`,
+ email: m.email,
+ membership_type: m.membership_type_name || 'Regular',
+ status: m.status_display_name || 'Unknown',
+ dues_status: m.dues_status,
+ annual_dues: m.annual_dues || 0,
+ last_payment_date: m.last_payment_date,
+ current_due_date: m.current_due_date,
+ days_overdue: m.days_overdue
+ }));
+
+ // Get all payments with member info
+ const { data: payments } = await supabaseAdmin
+ .from('dues_payments')
+ .select(
+ `
+ *,
+ member:members(member_id, first_name, last_name),
+ recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
+ `
+ )
+ .order('payment_date', { ascending: false });
+
+ const paymentReport = (payments || []).map((p: any) => ({
+ member_id: p.member?.member_id || 'Unknown',
+ member_name: p.member ? `${p.member.first_name} ${p.member.last_name}` : 'Unknown',
+ amount: p.amount,
+ payment_date: p.payment_date,
+ payment_method: p.payment_method,
+ reference: p.reference,
+ recorded_by: p.recorder ? `${p.recorder.first_name} ${p.recorder.last_name}` : null
+ }));
+
+ return {
+ members: memberReport,
+ payments: paymentReport
+ };
+}
+
+/**
+ * Get reminder effectiveness stats
+ */
+export async function getReminderEffectiveness(): Promise<{
+ totalRemindersSent: number;
+ paidWithin7Days: number;
+ paidWithin30Days: number;
+ effectivenessRate: number;
+}> {
+ // Get all reminder logs with payment data
+ const { data: reminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id, sent_at, due_date');
+
+ if (!reminders || reminders.length === 0) {
+ return {
+ totalRemindersSent: 0,
+ paidWithin7Days: 0,
+ paidWithin30Days: 0,
+ effectivenessRate: 0
+ };
+ }
+
+ let paidWithin7Days = 0;
+ let paidWithin30Days = 0;
+
+ for (const reminder of reminders) {
+ const sentDate = new Date(reminder.sent_at);
+ const sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000);
+ const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000);
+
+ // Check if member paid within windows
+ const { data: payments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('payment_date')
+ .eq('member_id', reminder.member_id)
+ .gte('payment_date', sentDate.toISOString().split('T')[0])
+ .lte('payment_date', thirtyDaysLater.toISOString().split('T')[0])
+ .limit(1);
+
+ if (payments && payments.length > 0) {
+ const paymentDate = new Date(payments[0].payment_date);
+ if (paymentDate <= sevenDaysLater) {
+ paidWithin7Days++;
+ }
+ paidWithin30Days++;
+ }
+ }
+
+ return {
+ totalRemindersSent: reminders.length,
+ paidWithin7Days,
+ paidWithin30Days,
+ effectivenessRate: reminders.length > 0 ? (paidWithin30Days / reminders.length) * 100 : 0
+ };
+}
+
+// ============================================
+// ONBOARDING REMINDERS
+// ============================================
+
+interface OnboardingMember {
+ id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ member_id: string;
+ payment_deadline: string;
+}
+
+/**
+ * Get new members who need onboarding payment reminders
+ * These are members with a payment_deadline set from onboarding
+ */
+export async function getMembersNeedingOnboardingReminder(
+ reminderType: OnboardingReminderType
+): Promise {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Get pending status ID
+ const { data: pendingStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'pending')
+ .single();
+
+ if (!pendingStatus) {
+ console.error('Pending status not found');
+ return [];
+ }
+
+ // Get members with payment_deadline set (from onboarding)
+ const { data: members, error } = await supabaseAdmin
+ .from('members')
+ .select('id, first_name, last_name, email, member_id, payment_deadline')
+ .eq('membership_status_id', pendingStatus.id)
+ .not('payment_deadline', 'is', null)
+ .not('email', 'is', null);
+
+ if (error || !members) {
+ console.error('Error fetching onboarding members:', error);
+ return [];
+ }
+
+ // Filter based on reminder type
+ let filteredMembers: OnboardingMember[] = [];
+
+ if (reminderType === 'onboarding_reminder_7') {
+ // 7 days or less until deadline
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ return daysUntil > 0 && daysUntil <= 7;
+ }) as OnboardingMember[];
+ } else if (reminderType === 'onboarding_reminder_1') {
+ // 1 day or less until deadline (final reminder)
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ return daysUntil === 1;
+ }) as OnboardingMember[];
+ } else if (reminderType === 'onboarding_expired') {
+ // Deadline has passed
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ return deadline < today;
+ }) as OnboardingMember[];
+ }
+
+ // Exclude members who already received this reminder
+ if (filteredMembers.length > 0) {
+ const memberIds = filteredMembers.map((m) => m.id);
+
+ const { data: existingReminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id')
+ .eq('reminder_type', reminderType)
+ .in('member_id', memberIds);
+
+ if (existingReminders && existingReminders.length > 0) {
+ const sentSet = new Set(existingReminders.map((r) => r.member_id));
+ filteredMembers = filteredMembers.filter((m) => !sentSet.has(m.id));
+ }
+ }
+
+ return filteredMembers;
+}
+
+/**
+ * Send an onboarding reminder to a specific member
+ */
+export async function sendOnboardingReminder(
+ member: OnboardingMember,
+ reminderType: OnboardingReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string }> {
+ const settings = await getDuesSettings();
+
+ // Calculate days until deadline
+ const deadline = new Date(member.payment_deadline);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const daysLeft = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+
+ // Get default membership dues amount
+ const { data: defaultType } = await supabaseAdmin
+ .from('membership_types')
+ .select('annual_dues')
+ .eq('is_default', true)
+ .single();
+
+ const variables: Record = {
+ first_name: member.first_name,
+ last_name: member.last_name,
+ member_id: member.member_id || 'N/A',
+ payment_deadline: deadline.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }),
+ days_remaining: Math.max(0, daysLeft).toString(),
+ amount: `€${defaultType?.annual_dues || 150}`,
+ account_holder: settings.payment_account_holder || 'Monaco USA',
+ bank_name: settings.payment_bank_name || 'Credit Foncier de Monaco',
+ iban: settings.payment_iban || 'Contact for details',
+ portal_url: `${baseUrl}/payments`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail(reminderType, member.email, variables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: reminderType,
+ due_date: member.payment_deadline
+ });
+
+ if (logError) {
+ console.error('Error logging onboarding reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send bulk onboarding reminders of a specific type
+ */
+export async function sendOnboardingReminders(
+ reminderType: OnboardingReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const members = await getMembersNeedingOnboardingReminder(reminderType);
+
+ const result: DuesReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ members: []
+ };
+
+ for (const member of members) {
+ try {
+ const sendResult = await sendOnboardingReminder(member, reminderType, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${member.email}: ${sendResult.error}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${member.email}: ${errorMessage}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Process expired onboarding payment deadlines and mark members as inactive
+ */
+export async function processOnboardingExpirations(
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> {
+ const members = await getMembersNeedingOnboardingReminder('onboarding_expired');
+ const processed: Array<{ id: string; name: string; email: string }> = [];
+
+ // Get inactive status ID
+ const { data: inactiveStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'inactive')
+ .single();
+
+ if (!inactiveStatus) {
+ console.error('Inactive status not found');
+ return { processed: 0, members: [] };
+ }
+
+ for (const member of members) {
+ // Update member status to inactive
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({ membership_status_id: inactiveStatus.id })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error(`Error updating member ${member.id}:`, updateError);
+ continue;
+ }
+
+ // Send expired notice
+ await sendOnboardingReminder(member, 'onboarding_expired', baseUrl);
+
+ processed.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email
+ });
+ }
+
+ return { processed: processed.length, members: processed };
+}
diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts
new file mode 100644
index 0000000..6b5d884
--- /dev/null
+++ b/src/lib/server/email.ts
@@ -0,0 +1,394 @@
+import nodemailer from 'nodemailer';
+import type { Transporter } from 'nodemailer';
+import { supabaseAdmin } from './supabase';
+
+export interface SmtpConfig {
+ host: string;
+ port: number;
+ secure: boolean;
+ username: string;
+ password: string;
+ from_address: string;
+ from_name: string;
+}
+
+export interface SendEmailOptions {
+ to: string;
+ subject: string;
+ html: string;
+ text?: string;
+ recipientId?: string;
+ recipientName?: string;
+ templateKey?: string;
+ emailType?: string;
+ sentBy?: string;
+}
+
+/**
+ * Get SMTP configuration from app_settings table
+ */
+export async function getSmtpConfig(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'email');
+
+ if (!settings || settings.length === 0) {
+ return null;
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ // Parse the value - it might be JSON stringified or plain
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ // Remove surrounding quotes if present
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ // Validate required fields
+ if (!config.smtp_host || !config.smtp_username || !config.smtp_password) {
+ return null;
+ }
+
+ return {
+ host: config.smtp_host,
+ port: parseInt(config.smtp_port || '587'),
+ secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
+ username: config.smtp_username,
+ password: config.smtp_password,
+ from_address: config.smtp_from_address || 'noreply@monacousa.org',
+ from_name: config.smtp_from_name || 'Monaco USA'
+ };
+}
+
+/**
+ * Create a nodemailer transporter with the configured SMTP settings
+ */
+export async function createTransporter(): Promise {
+ const config = await getSmtpConfig();
+ if (!config) {
+ console.error('SMTP configuration not found or incomplete');
+ return null;
+ }
+
+ return nodemailer.createTransport({
+ host: config.host,
+ port: config.port,
+ secure: config.secure,
+ auth: {
+ user: config.username,
+ pass: config.password
+ }
+ });
+}
+
+/**
+ * Send an email using the configured SMTP settings
+ */
+export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string; messageId?: string }> {
+ const config = await getSmtpConfig();
+ if (!config) {
+ return { success: false, error: 'SMTP not configured. Please configure email settings first.' };
+ }
+
+ const transporter = await createTransporter();
+ if (!transporter) {
+ return { success: false, error: 'Failed to create email transporter' };
+ }
+
+ try {
+ const result = await transporter.sendMail({
+ from: `"${config.from_name}" <${config.from_address}>`,
+ to: options.to,
+ subject: options.subject,
+ html: options.html,
+ text: options.text || stripHtml(options.html)
+ });
+
+ // Log to email_logs table
+ await supabaseAdmin.from('email_logs').insert({
+ recipient_id: options.recipientId || null,
+ recipient_email: options.to,
+ recipient_name: options.recipientName || null,
+ template_key: options.templateKey || null,
+ subject: options.subject,
+ email_type: options.emailType || 'manual',
+ status: 'sent',
+ provider: 'smtp',
+ provider_message_id: result.messageId,
+ sent_by: options.sentBy || null,
+ sent_at: new Date().toISOString()
+ });
+
+ return { success: true, messageId: result.messageId };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Email send error:', error);
+
+ // Log failed attempt
+ await supabaseAdmin.from('email_logs').insert({
+ recipient_id: options.recipientId || null,
+ recipient_email: options.to,
+ recipient_name: options.recipientName || null,
+ template_key: options.templateKey || null,
+ subject: options.subject,
+ email_type: options.emailType || 'manual',
+ status: 'failed',
+ provider: 'smtp',
+ error_message: errorMessage,
+ sent_by: options.sentBy || null
+ });
+
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Send a templated email with variable substitution
+ * Templates should contain content only (no full HTML wrapper) - will be wrapped automatically
+ */
+export async function sendTemplatedEmail(
+ templateKey: string,
+ to: string,
+ variables: Record,
+ options?: {
+ recipientId?: string;
+ recipientName?: string;
+ sentBy?: string;
+ baseUrl?: string;
+ }
+): Promise<{ success: boolean; error?: string; messageId?: string }> {
+ // Fetch template from database
+ const { data: template, error: templateError } = await supabaseAdmin
+ .from('email_templates')
+ .select('*')
+ .eq('template_key', templateKey)
+ .eq('is_active', true)
+ .single();
+
+ if (templateError || !template) {
+ return { success: false, error: `Email template "${templateKey}" not found or inactive` };
+ }
+
+ // Get site URL for logo
+ const baseUrl = options?.baseUrl || process.env.SITE_URL || 'https://monacousa.org';
+ const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+
+ // Add default variables
+ const allVariables: Record = {
+ logo_url: logoUrl,
+ site_url: baseUrl,
+ ...variables
+ };
+
+ // Replace variables in subject and body
+ let subject = template.subject;
+ let bodyContent = template.body_html;
+ let text = template.body_text || '';
+
+ for (const [key, value] of Object.entries(allVariables)) {
+ const regex = new RegExp(`{{${key}}}`, 'g');
+ subject = subject.replace(regex, value);
+ bodyContent = bodyContent.replace(regex, value);
+ text = text.replace(regex, value);
+ }
+
+ // Extract title from template or use subject
+ // Look for title in template metadata or first h2 tag
+ let emailTitle = template.email_title || subject;
+ // Try to extract from first h2 in content
+ const h2Match = bodyContent.match(/]*>([^<]+)<\/h2>/i);
+ if (h2Match) {
+ emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim();
+ }
+
+ // Check if template already has full HTML wrapper (legacy templates)
+ const hasFullWrapper = bodyContent.includes('/g,
+ ` `
+ );
+ } else {
+ // Content-only template - wrap with Monaco template
+ html = wrapInMonacoTemplate({
+ title: emailTitle,
+ content: bodyContent,
+ logoUrl
+ });
+ }
+
+ return sendEmail({
+ to,
+ subject,
+ html,
+ text: text || undefined,
+ recipientId: options?.recipientId,
+ recipientName: options?.recipientName,
+ templateKey,
+ emailType: template.category,
+ sentBy: options?.sentBy
+ });
+}
+
+/**
+ * Test SMTP connection and optionally send a test email
+ */
+export async function testSmtpConnection(
+ sendTo?: string,
+ sentBy?: string
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getSmtpConfig();
+ if (!config) {
+ return { success: false, error: 'SMTP not configured. Please configure and save email settings first.' };
+ }
+
+ const transporter = await createTransporter();
+ if (!transporter) {
+ return { success: false, error: 'Failed to create email transporter' };
+ }
+
+ try {
+ // Verify connection
+ await transporter.verify();
+
+ // If a recipient is provided, send a test email
+ if (sendTo) {
+ const testContent = `
+ This is a test email from your Monaco USA Portal.
+
+
✓ Configuration Verified
+
Your SMTP settings are working correctly!
+
+ Sent at ${new Date().toLocaleString()}
`;
+
+ const result = await sendEmail({
+ to: sendTo,
+ subject: 'Monaco USA Portal - SMTP Test Email',
+ html: wrapInMonacoTemplate({
+ title: 'SMTP Test Successful!',
+ content: testContent
+ }),
+ emailType: 'test',
+ sentBy
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+ }
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('SMTP test error:', error);
+ return { success: false, error: `SMTP connection failed: ${errorMessage}` };
+ }
+}
+
+// S3-hosted background image URL matching login screen
+const EMAIL_BACKGROUND_IMAGE_URL = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
+
+/**
+ * Wrap email content in Monaco-branded template
+ * This creates a consistent look matching the login page styling with background image
+ */
+export function wrapInMonacoTemplate(options: {
+ title: string;
+ content: string;
+ logoUrl?: string;
+ backgroundImageUrl?: string;
+}): string {
+ const baseUrl = process.env.SITE_URL || 'http://localhost:7453';
+ const logoUrl = options.logoUrl || `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+ const bgImageUrl = options.backgroundImageUrl || EMAIL_BACKGROUND_IMAGE_URL;
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ ${options.title}
+ ${options.content}
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+/**
+ * Strip HTML tags from a string to create plain text version
+ */
+function stripHtml(html: string): string {
+ return html
+ .replace(/<[^>]*>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+}
diff --git a/src/lib/server/event-reminders.ts b/src/lib/server/event-reminders.ts
new file mode 100644
index 0000000..33fe494
--- /dev/null
+++ b/src/lib/server/event-reminders.ts
@@ -0,0 +1,339 @@
+/**
+ * Event Reminder Service
+ * Handles sending automated reminder emails 24 hours before events
+ */
+
+import { supabaseAdmin } from './supabase';
+import { sendTemplatedEmail } from './email';
+
+// ============================================
+// TYPES
+// ============================================
+
+export interface EventReminderSettings {
+ event_reminders_enabled: boolean;
+ event_reminder_hours_before: number;
+}
+
+export interface EventReminderResult {
+ sent: number;
+ skipped: number;
+ errors: string[];
+ reminders: Array<{
+ eventId: string;
+ eventTitle: string;
+ memberId: string;
+ memberName: string;
+ email: string;
+ status: 'sent' | 'skipped' | 'error';
+ error?: string;
+ }>;
+}
+
+export interface EventNeedingReminder {
+ event_id: string;
+ event_title: string;
+ start_datetime: string;
+ end_datetime: string;
+ location: string | null;
+ timezone: string;
+ rsvp_id: string;
+ member_id: string;
+ guest_count: number;
+ rsvp_status: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+}
+
+// ============================================
+// SETTINGS
+// ============================================
+
+/**
+ * Get event reminder settings from the database
+ */
+export async function getEventReminderSettings(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'events')
+ .in('setting_key', ['event_reminders_enabled', 'event_reminder_hours_before']);
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ config[s.setting_key] = s.setting_value as string;
+ }
+
+ return {
+ event_reminders_enabled: config.event_reminders_enabled !== 'false',
+ event_reminder_hours_before: parseInt(config.event_reminder_hours_before || '24')
+ };
+}
+
+// ============================================
+// QUERIES
+// ============================================
+
+/**
+ * Get events with confirmed RSVPs that need reminders sent
+ * Uses the events_needing_reminders view for efficient querying
+ */
+export async function getEventsNeedingReminders(): Promise {
+ const settings = await getEventReminderSettings();
+
+ if (!settings.event_reminders_enabled) {
+ return [];
+ }
+
+ // Calculate the time window based on settings
+ const hoursBeforeEvent = settings.event_reminder_hours_before;
+ const now = new Date();
+ const windowStart = new Date(now.getTime() + (hoursBeforeEvent - 1) * 60 * 60 * 1000);
+ const windowEnd = new Date(now.getTime() + (hoursBeforeEvent + 1) * 60 * 60 * 1000);
+
+ // Query events starting within the reminder window
+ const { data: events, error } = await supabaseAdmin
+ .from('events')
+ .select(`
+ id,
+ title,
+ start_datetime,
+ end_datetime,
+ location,
+ timezone
+ `)
+ .eq('status', 'published')
+ .gt('start_datetime', windowStart.toISOString())
+ .lte('start_datetime', windowEnd.toISOString());
+
+ if (error || !events || events.length === 0) {
+ return [];
+ }
+
+ // Get RSVPs for these events
+ const eventIds = events.map(e => e.id);
+ const { data: rsvps, error: rsvpError } = await supabaseAdmin
+ .from('event_rsvps')
+ .select(`
+ id,
+ event_id,
+ member_id,
+ guest_count,
+ status,
+ member:members(first_name, last_name, email)
+ `)
+ .in('event_id', eventIds)
+ .eq('status', 'confirmed');
+
+ if (rsvpError || !rsvps) {
+ return [];
+ }
+
+ // Get already sent reminders
+ const { data: sentReminders } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('event_id, member_id')
+ .in('event_id', eventIds)
+ .eq('reminder_type', '24hr');
+
+ const sentSet = new Set(
+ (sentReminders || []).map(r => `${r.event_id}-${r.member_id}`)
+ );
+
+ // Build the result array
+ const result: EventNeedingReminder[] = [];
+
+ for (const event of events) {
+ const eventRsvps = rsvps.filter(r => r.event_id === event.id);
+
+ for (const rsvp of eventRsvps) {
+ const member = rsvp.member as { first_name: string; last_name: string; email: string } | null;
+ if (!member?.email) continue;
+
+ // Skip if reminder already sent
+ const key = `${event.id}-${rsvp.member_id}`;
+ if (sentSet.has(key)) continue;
+
+ result.push({
+ event_id: event.id,
+ event_title: event.title,
+ start_datetime: event.start_datetime,
+ end_datetime: event.end_datetime,
+ location: event.location,
+ timezone: event.timezone || 'Europe/Monaco',
+ rsvp_id: rsvp.id,
+ member_id: rsvp.member_id,
+ guest_count: rsvp.guest_count || 0,
+ rsvp_status: rsvp.status,
+ first_name: member.first_name,
+ last_name: member.last_name,
+ email: member.email
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// REMINDER SENDING
+// ============================================
+
+/**
+ * Send a single event reminder email
+ */
+export async function sendEventReminder(
+ reminder: EventNeedingReminder,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string }> {
+ // Format date and time
+ const eventDate = new Date(reminder.start_datetime);
+ const formattedDate = eventDate.toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ const formattedTime = eventDate.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ timeZone: reminder.timezone
+ });
+
+ const variables: Record = {
+ first_name: reminder.first_name,
+ event_title: reminder.event_title,
+ event_date: formattedDate,
+ event_time: formattedTime,
+ event_location: reminder.location || 'TBD',
+ guest_count: reminder.guest_count > 0 ? reminder.guest_count.toString() : '',
+ portal_url: `${baseUrl}/events/${reminder.event_id}`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail('event_reminder_24hr', reminder.email, variables, {
+ recipientId: reminder.member_id,
+ recipientName: `${reminder.first_name} ${reminder.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('event_reminder_logs').insert({
+ event_id: reminder.event_id,
+ rsvp_id: reminder.rsvp_id,
+ member_id: reminder.member_id,
+ reminder_type: '24hr'
+ });
+
+ if (logError) {
+ console.error('Error logging event reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send all pending event reminders
+ */
+export async function sendEventReminders(
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const remindersNeeded = await getEventsNeedingReminders();
+
+ const result: EventReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ reminders: []
+ };
+
+ for (const reminder of remindersNeeded) {
+ try {
+ const sendResult = await sendEventReminder(reminder, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${reminder.email}: ${sendResult.error}`);
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${reminder.email}: ${errorMessage}`);
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// ANALYTICS
+// ============================================
+
+/**
+ * Get statistics about event reminders
+ */
+export async function getEventReminderStats(): Promise<{
+ totalRemindersSent: number;
+ remindersSentThisMonth: number;
+ eventsWithReminders: number;
+}> {
+ const now = new Date();
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+
+ // Get total reminders sent
+ const { count: totalRemindersSent } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('*', { count: 'exact', head: true });
+
+ // Get reminders sent this month
+ const { count: remindersSentThisMonth } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('*', { count: 'exact', head: true })
+ .gte('sent_at', startOfMonth.toISOString());
+
+ // Get unique events with reminders
+ const { data: uniqueEvents } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('event_id')
+ .limit(10000);
+
+ const uniqueEventIds = new Set((uniqueEvents || []).map(e => e.event_id));
+
+ return {
+ totalRemindersSent: totalRemindersSent || 0,
+ remindersSentThisMonth: remindersSentThisMonth || 0,
+ eventsWithReminders: uniqueEventIds.size
+ };
+}
diff --git a/src/lib/server/ical.ts b/src/lib/server/ical.ts
new file mode 100644
index 0000000..44a4c7a
--- /dev/null
+++ b/src/lib/server/ical.ts
@@ -0,0 +1,299 @@
+/**
+ * iCal Calendar Generation Utilities
+ * Generate .ics files for events and calendar feeds
+ */
+
+export interface ICalEvent {
+ id: string;
+ title: string;
+ description?: string;
+ start_datetime: string;
+ end_datetime: string;
+ location?: string | null;
+ location_url?: string | null;
+ timezone?: string;
+ status?: 'published' | 'cancelled' | 'draft';
+ event_type_name?: string | null;
+ organizer_name?: string;
+ organizer_email?: string;
+ url?: string;
+ all_day?: boolean;
+}
+
+/**
+ * Escape special characters for iCal format
+ */
+function escapeICalText(text: string): string {
+ return text
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '');
+}
+
+/**
+ * Format a date for iCal (YYYYMMDDTHHMMSSZ format for UTC)
+ */
+function formatICalDate(dateStr: string, timezone?: string): string {
+ const date = new Date(dateStr);
+ // Format as UTC
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(date.getUTCDate()).padStart(2, '0');
+ const hours = String(date.getUTCHours()).padStart(2, '0');
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
+ const seconds = String(date.getUTCSeconds()).padStart(2, '0');
+ return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
+}
+
+/**
+ * Format a date for all-day events (YYYYMMDD format)
+ */
+function formatICalDateOnly(dateStr: string): string {
+ const date = new Date(dateStr);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}${month}${day}`;
+}
+
+/**
+ * Map event status to iCal status
+ */
+function getICalStatus(status?: string): string {
+ switch (status) {
+ case 'cancelled':
+ return 'CANCELLED';
+ case 'draft':
+ return 'TENTATIVE';
+ default:
+ return 'CONFIRMED';
+ }
+}
+
+/**
+ * Fold long lines according to iCal spec (max 75 chars per line)
+ */
+function foldLine(line: string): string {
+ const maxLength = 75;
+ if (line.length <= maxLength) {
+ return line;
+ }
+
+ const result: string[] = [];
+ let remaining = line;
+
+ // First line can be full length
+ result.push(remaining.substring(0, maxLength));
+ remaining = remaining.substring(maxLength);
+
+ // Continuation lines start with a space and have maxLength-1 content
+ while (remaining.length > 0) {
+ result.push(' ' + remaining.substring(0, maxLength - 1));
+ remaining = remaining.substring(maxLength - 1);
+ }
+
+ return result.join('\r\n');
+}
+
+/**
+ * Generate a single iCal event entry
+ */
+export function generateICalEvent(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const uid = `${event.id}@monacousa.org`;
+ const dtstamp = formatICalDate(new Date().toISOString());
+ const created = dtstamp;
+ const lastModified = dtstamp;
+
+ const lines: string[] = [
+ 'BEGIN:VEVENT',
+ `UID:${uid}`,
+ `DTSTAMP:${dtstamp}`,
+ `CREATED:${created}`,
+ `LAST-MODIFIED:${lastModified}`
+ ];
+
+ // Date/time
+ if (event.all_day) {
+ lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(event.start_datetime)}`);
+ // For all-day events, end date is exclusive, so add one day
+ const endDate = new Date(event.end_datetime);
+ endDate.setDate(endDate.getDate() + 1);
+ lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(endDate.toISOString())}`);
+ } else {
+ lines.push(`DTSTART:${formatICalDate(event.start_datetime, event.timezone)}`);
+ lines.push(`DTEND:${formatICalDate(event.end_datetime, event.timezone)}`);
+ }
+
+ // Summary (title)
+ lines.push(foldLine(`SUMMARY:${escapeICalText(event.title)}`));
+
+ // Description
+ if (event.description) {
+ lines.push(foldLine(`DESCRIPTION:${escapeICalText(event.description)}`));
+ }
+
+ // Location
+ if (event.location) {
+ lines.push(foldLine(`LOCATION:${escapeICalText(event.location)}`));
+ }
+
+ // URL
+ const eventUrl = event.url || `${baseUrl}/events/${event.id}`;
+ lines.push(`URL:${eventUrl}`);
+
+ // Status
+ lines.push(`STATUS:${getICalStatus(event.status)}`);
+
+ // Categories
+ if (event.event_type_name) {
+ lines.push(`CATEGORIES:${escapeICalText(event.event_type_name)}`);
+ }
+
+ // Organizer
+ if (event.organizer_email) {
+ const organizerName = event.organizer_name || 'Monaco USA';
+ lines.push(`ORGANIZER;CN=${escapeICalText(organizerName)}:mailto:${event.organizer_email}`);
+ }
+
+ // Sequence (for updates)
+ lines.push('SEQUENCE:0');
+
+ lines.push('END:VEVENT');
+
+ return lines.join('\r\n');
+}
+
+/**
+ * Generate a complete iCal calendar file for a single event
+ */
+export function generateSingleEventIcal(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Monaco USA//Event Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ `X-WR-CALNAME:${escapeICalText(event.title)}`,
+ 'X-WR-TIMEZONE:Europe/Monaco',
+ generateICalEvent(event, baseUrl),
+ 'END:VCALENDAR'
+ ];
+
+ return lines.join('\r\n') + '\r\n';
+}
+
+/**
+ * Generate a complete iCal calendar feed for multiple events
+ */
+export function generateCalendarFeed(
+ events: ICalEvent[],
+ calendarName: string = 'Monaco USA Events',
+ baseUrl: string = 'https://monacousa.org'
+): string {
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Monaco USA//Event Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ `X-WR-CALNAME:${escapeICalText(calendarName)}`,
+ 'X-WR-TIMEZONE:Europe/Monaco',
+ // Refresh interval hint (1 hour)
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
+ 'X-PUBLISHED-TTL:PT1H'
+ ];
+
+ // Add each event
+ for (const event of events) {
+ lines.push(generateICalEvent(event, baseUrl));
+ }
+
+ lines.push('END:VCALENDAR');
+
+ return lines.join('\r\n') + '\r\n';
+}
+
+/**
+ * Generate a Google Calendar URL for an event
+ */
+export function generateGoogleCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Format: YYYYMMDDTHHMMSSZ
+ const formatGoogleDate = (date: Date) => {
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ };
+
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: event.title,
+ dates: `${formatGoogleDate(start)}/${formatGoogleDate(end)}`,
+ details: event.description || '',
+ location: event.location || '',
+ sprop: `website:${baseUrl}`,
+ sf: 'true',
+ output: 'xml'
+ });
+
+ return `https://www.google.com/calendar/render?${params.toString()}`;
+}
+
+/**
+ * Generate an Outlook.com calendar URL for an event
+ */
+export function generateOutlookCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Outlook uses ISO format with URL encoding
+ const params = new URLSearchParams({
+ rru: 'addevent',
+ startdt: start.toISOString(),
+ enddt: end.toISOString(),
+ subject: event.title,
+ body: event.description || '',
+ location: event.location || '',
+ path: '/calendar/action/compose'
+ });
+
+ return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
+}
+
+/**
+ * Generate a Yahoo Calendar URL for an event
+ */
+export function generateYahooCalendarUrl(event: ICalEvent): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Duration in hours and minutes
+ const durationMs = end.getTime() - start.getTime();
+ const hours = Math.floor(durationMs / (1000 * 60 * 60));
+ const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
+ const duration = `${String(hours).padStart(2, '0')}${String(minutes).padStart(2, '0')}`;
+
+ // Format: YYYYMMDDTHHMMSS
+ const formatYahooDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const h = String(date.getHours()).padStart(2, '0');
+ const m = String(date.getMinutes()).padStart(2, '0');
+ const s = String(date.getSeconds()).padStart(2, '0');
+ return `${year}${month}${day}T${h}${m}${s}`;
+ };
+
+ const params = new URLSearchParams({
+ v: '60',
+ title: event.title,
+ st: formatYahooDate(start),
+ dur: duration,
+ desc: event.description || '',
+ in_loc: event.location || ''
+ });
+
+ return `https://calendar.yahoo.com/?${params.toString()}`;
+}
diff --git a/src/lib/server/poste.ts b/src/lib/server/poste.ts
new file mode 100644
index 0000000..509c135
--- /dev/null
+++ b/src/lib/server/poste.ts
@@ -0,0 +1,303 @@
+/**
+ * Poste.io Mail Server API Client
+ * Documentation: https://mail.monacousa.org/admin/api/doc
+ */
+
+export interface PosteConfig {
+ host: string;
+ adminEmail: string;
+ adminPassword: string;
+}
+
+export interface Mailbox {
+ address: string;
+ name: string;
+ disabled: boolean;
+ super_admin: boolean;
+ created?: string;
+ storage_limit?: number;
+ storage_usage?: number;
+}
+
+export interface MailboxQuota {
+ storageLimit: number;
+ storageUsed: number;
+}
+
+interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+/**
+ * Make an authenticated request to the Poste API
+ */
+async function makeRequest(
+ config: PosteConfig,
+ method: string,
+ endpoint: string,
+ body?: Record
+): Promise> {
+ const baseUrl = `https://${config.host}/admin/api/v1`;
+ const auth = Buffer.from(`${config.adminEmail}:${config.adminPassword}`).toString('base64');
+
+ try {
+ const response = await fetch(`${baseUrl}${endpoint}`, {
+ method,
+ headers: {
+ 'Authorization': `Basic ${auth}`,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: body ? JSON.stringify(body) : undefined
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ let errorMessage = `HTTP ${response.status}`;
+ try {
+ const errorJson = JSON.parse(errorText);
+ errorMessage = errorJson.message || errorJson.error || errorMessage;
+ } catch {
+ errorMessage = errorText || errorMessage;
+ }
+ return { success: false, error: errorMessage };
+ }
+
+ // Handle empty responses (e.g., DELETE)
+ const text = await response.text();
+ if (!text) {
+ return { success: true };
+ }
+
+ const data = JSON.parse(text) as T;
+ return { success: true, data };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: message };
+ }
+}
+
+/**
+ * Test connection to the Poste API
+ */
+export async function testConnection(config: PosteConfig): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest<{ results: unknown[] }>(config, 'GET', '/domains');
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+ return { success: true };
+}
+
+/**
+ * List all mailboxes
+ */
+export async function listMailboxes(
+ config: PosteConfig,
+ options?: { query?: string; page?: number; limit?: number }
+): Promise<{ success: boolean; mailboxes?: Mailbox[]; total?: number; error?: string }> {
+ let endpoint = '/boxes';
+ const params = new URLSearchParams();
+
+ if (options?.query) params.set('query', options.query);
+ if (options?.page) params.set('page', options.page.toString());
+ if (options?.limit) params.set('paging', options.limit.toString());
+
+ const queryString = params.toString();
+ if (queryString) endpoint += `?${queryString}`;
+
+ const result = await makeRequest<{ results: Mailbox[]; results_count: number }>(
+ config,
+ 'GET',
+ endpoint
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ mailboxes: result.data?.results || [],
+ total: result.data?.results_count || 0
+ };
+}
+
+/**
+ * Get a single mailbox
+ */
+export async function getMailbox(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; mailbox?: Mailbox; error?: string }> {
+ const result = await makeRequest(config, 'GET', `/boxes/${encodeURIComponent(email)}`);
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true, mailbox: result.data };
+}
+
+/**
+ * Create a new mailbox
+ */
+export async function createMailbox(
+ config: PosteConfig,
+ options: {
+ email: string;
+ name: string;
+ password: string;
+ disabled?: boolean;
+ superAdmin?: boolean;
+ }
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(config, 'POST', '/boxes', {
+ email: options.email,
+ name: options.name,
+ passwordPlaintext: options.password,
+ disabled: options.disabled ?? false,
+ superAdmin: options.superAdmin ?? false
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Update a mailbox
+ */
+export async function updateMailbox(
+ config: PosteConfig,
+ email: string,
+ updates: {
+ name?: string;
+ password?: string;
+ disabled?: boolean;
+ superAdmin?: boolean;
+ }
+): Promise<{ success: boolean; error?: string }> {
+ const body: Record = {};
+
+ if (updates.name !== undefined) body.name = updates.name;
+ if (updates.password !== undefined) body.passwordPlaintext = updates.password;
+ if (updates.disabled !== undefined) body.disabled = updates.disabled;
+ if (updates.superAdmin !== undefined) body.superAdmin = updates.superAdmin;
+
+ const result = await makeRequest(
+ config,
+ 'PATCH',
+ `/boxes/${encodeURIComponent(email)}`,
+ body
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Delete a mailbox
+ */
+export async function deleteMailbox(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(
+ config,
+ 'DELETE',
+ `/boxes/${encodeURIComponent(email)}`
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get mailbox storage quota
+ */
+export async function getMailboxQuota(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; quota?: MailboxQuota; error?: string }> {
+ const result = await makeRequest<{ storageLimit: number; storageUsed: number }>(
+ config,
+ 'GET',
+ `/boxes/${encodeURIComponent(email)}/quota`
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ quota: {
+ storageLimit: result.data?.storageLimit || 0,
+ storageUsed: result.data?.storageUsed || 0
+ }
+ };
+}
+
+/**
+ * Set mailbox storage quota
+ */
+export async function setMailboxQuota(
+ config: PosteConfig,
+ email: string,
+ storageLimitMB: number
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(
+ config,
+ 'PATCH',
+ `/boxes/${encodeURIComponent(email)}/quota`,
+ { storageLimit: storageLimitMB }
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * List all domains
+ */
+export async function listDomains(
+ config: PosteConfig
+): Promise<{ success: boolean; domains?: string[]; error?: string }> {
+ const result = await makeRequest<{ results: { name: string }[] }>(config, 'GET', '/domains');
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ domains: result.data?.results?.map(d => d.name) || []
+ };
+}
+
+/**
+ * Generate a random password
+ */
+export function generatePassword(length = 16): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
+ let password = '';
+ const randomValues = new Uint32Array(length);
+ crypto.getRandomValues(randomValues);
+ for (let i = 0; i < length; i++) {
+ password += chars[randomValues[i] % chars.length];
+ }
+ return password;
+}
diff --git a/src/lib/server/storage.ts b/src/lib/server/storage.ts
new file mode 100644
index 0000000..ce7d7d1
--- /dev/null
+++ b/src/lib/server/storage.ts
@@ -0,0 +1,901 @@
+import { supabaseAdmin } from './supabase';
+import { PUBLIC_SUPABASE_URL } from '$env/static/public';
+import {
+ S3Client,
+ PutObjectCommand,
+ GetObjectCommand,
+ DeleteObjectCommand,
+ DeleteObjectsCommand,
+ ListObjectsV2Command,
+ HeadBucketCommand
+} from '@aws-sdk/client-s3';
+import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
+
+export type StorageBucket = 'documents' | 'avatars' | 'event-images';
+
+/**
+ * Generate a browser-accessible public URL for Supabase Storage
+ * This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
+ */
+function getBrowserAccessibleUrl(bucket: StorageBucket, path: string): string {
+ return `${PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`;
+}
+
+export interface UploadResult {
+ success: boolean;
+ path?: string;
+ publicUrl?: string;
+ localUrl?: string;
+ s3Url?: string;
+ error?: string;
+}
+
+export interface S3Config {
+ endpoint: string;
+ bucket: string;
+ accessKey: string;
+ secretKey: string;
+ region: string;
+ useSSL: boolean;
+ forcePathStyle: boolean;
+ enabled: boolean;
+}
+
+let s3ClientCache: S3Client | null = null;
+let s3ConfigCache: S3Config | null = null;
+let s3ConfigCacheTime: number = 0;
+const S3_CONFIG_CACHE_TTL = 60000; // 1 minute cache
+
+/**
+ * Get S3 configuration from app_settings table
+ */
+export async function getS3Config(): Promise {
+ // Check cache
+ if (s3ConfigCache && Date.now() - s3ConfigCacheTime < S3_CONFIG_CACHE_TTL) {
+ return s3ConfigCache;
+ }
+
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'storage');
+
+ if (!settings || settings.length === 0) {
+ return null;
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ // Remove surrounding quotes if present (from JSON stringified values)
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value;
+ }
+
+ // Check if S3 is enabled - handle both boolean true and string 'true'
+ const isEnabled = config.s3_enabled === true || config.s3_enabled === 'true';
+
+ // Check if S3 is enabled and configured
+ if (!isEnabled || !config.s3_endpoint || !config.s3_access_key || !config.s3_secret_key) {
+ console.log('S3 config check failed:', {
+ isEnabled,
+ hasEndpoint: !!config.s3_endpoint,
+ hasAccessKey: !!config.s3_access_key,
+ hasSecretKey: !!config.s3_secret_key
+ });
+ return null;
+ }
+
+ s3ConfigCache = {
+ endpoint: config.s3_endpoint,
+ bucket: config.s3_bucket || 'monacousa-documents',
+ accessKey: config.s3_access_key,
+ secretKey: config.s3_secret_key,
+ region: config.s3_region || 'us-east-1',
+ useSSL: config.s3_use_ssl === true || config.s3_use_ssl === 'true',
+ forcePathStyle: config.s3_force_path_style === true || config.s3_force_path_style === 'true' || config.s3_force_path_style === undefined,
+ enabled: true
+ };
+ s3ConfigCacheTime = Date.now();
+
+ return s3ConfigCache;
+}
+
+/**
+ * Get or create S3 client
+ */
+export async function getS3Client(): Promise {
+ const config = await getS3Config();
+ if (!config) {
+ return null;
+ }
+
+ // Return cached client if config hasn't changed
+ if (s3ClientCache && s3ConfigCache) {
+ return s3ClientCache;
+ }
+
+ s3ClientCache = new S3Client({
+ endpoint: config.endpoint,
+ region: config.region,
+ credentials: {
+ accessKeyId: config.accessKey,
+ secretAccessKey: config.secretKey
+ },
+ forcePathStyle: config.forcePathStyle
+ });
+
+ return s3ClientCache;
+}
+
+/**
+ * Clear S3 client cache (call when settings change)
+ */
+export function clearS3ClientCache(): void {
+ s3ClientCache = null;
+ s3ConfigCache = null;
+ s3ConfigCacheTime = 0;
+}
+
+/**
+ * Test S3 connection
+ */
+export async function testS3Connection(): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ if (!config) {
+ return { success: false, error: 'S3 not configured. Please configure and enable S3 storage settings first.' };
+ }
+
+ const client = await getS3Client();
+ if (!client) {
+ return { success: false, error: 'Failed to create S3 client' };
+ }
+
+ try {
+ await client.send(new HeadBucketCommand({ Bucket: config.bucket }));
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 connection test error:', error);
+ return { success: false, error: `S3 connection failed: ${errorMessage}` };
+ }
+}
+
+/**
+ * Check if S3 storage is enabled
+ */
+export async function isS3Enabled(): Promise {
+ const config = await getS3Config();
+ return config !== null && config.enabled;
+}
+
+/**
+ * Get the S3 key with bucket prefix for organization
+ */
+function getS3Key(bucket: StorageBucket, path: string): string {
+ return `${bucket}/${path}`;
+}
+
+/**
+ * Upload a file to S3
+ */
+async function uploadToS3(
+ bucket: StorageBucket,
+ path: string,
+ file: File | ArrayBuffer | Buffer,
+ options?: {
+ contentType?: string;
+ }
+): Promise {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ let body: Buffer;
+
+ if (file instanceof ArrayBuffer) {
+ body = Buffer.from(file);
+ } else if (Buffer.isBuffer(file)) {
+ body = file;
+ } else {
+ // It's a File object
+ body = Buffer.from(await file.arrayBuffer());
+ }
+
+ await client.send(
+ new PutObjectCommand({
+ Bucket: config.bucket,
+ Key: key,
+ Body: body,
+ ContentType: options?.contentType
+ })
+ );
+
+ // Construct public URL
+ const protocol = config.useSSL ? 'https' : 'http';
+ let publicUrl: string;
+ if (config.forcePathStyle) {
+ publicUrl = `${config.endpoint}/${config.bucket}/${key}`;
+ } else {
+ publicUrl = `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
+ }
+
+ return {
+ success: true,
+ path: key,
+ publicUrl
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 upload error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Get a signed URL from S3
+ */
+async function getS3PresignedUrl(
+ bucket: StorageBucket,
+ path: string,
+ expiresIn: number = 3600
+): Promise<{ url: string | null; error: string | null }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { url: null, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ const command = new GetObjectCommand({
+ Bucket: config.bucket,
+ Key: key
+ });
+
+ const url = await getS3SignedUrl(client, command, { expiresIn });
+ return { url, error: null };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 signed URL error:', error);
+ return { url: null, error: errorMessage };
+ }
+}
+
+/**
+ * Delete a file from S3
+ */
+async function deleteFromS3(
+ bucket: StorageBucket,
+ path: string
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ await client.send(
+ new DeleteObjectCommand({
+ Bucket: config.bucket,
+ Key: key
+ })
+ );
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 delete error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Delete multiple files from S3
+ */
+async function deleteMultipleFromS3(
+ bucket: StorageBucket,
+ paths: string[]
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const objects = paths.map((p) => ({ Key: getS3Key(bucket, p) }));
+ await client.send(
+ new DeleteObjectsCommand({
+ Bucket: config.bucket,
+ Delete: { Objects: objects }
+ })
+ );
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 delete multiple error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * List files from S3
+ */
+async function listFilesFromS3(
+ bucket: StorageBucket,
+ folder?: string,
+ options?: {
+ limit?: number;
+ }
+): Promise<{ files: any[]; error: string | null }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { files: [], error: 'S3 not configured' };
+ }
+
+ try {
+ const prefix = folder ? `${bucket}/${folder}/` : `${bucket}/`;
+ const response = await client.send(
+ new ListObjectsV2Command({
+ Bucket: config.bucket,
+ Prefix: prefix,
+ MaxKeys: options?.limit || 100
+ })
+ );
+
+ const files = (response.Contents || []).map((obj) => ({
+ name: obj.Key?.replace(prefix, '') || '',
+ size: obj.Size,
+ updated_at: obj.LastModified?.toISOString(),
+ created_at: obj.LastModified?.toISOString()
+ }));
+
+ return { files, error: null };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 list error:', error);
+ return { files: [], error: errorMessage };
+ }
+}
+
+// ===========================================
+// PUBLIC API - Uses S3 or Supabase based on settings
+// ===========================================
+
+/**
+ * Upload a file to storage (S3 or Supabase)
+ */
+export async function uploadFile(
+ bucket: StorageBucket,
+ path: string,
+ file: File | ArrayBuffer,
+ options?: {
+ contentType?: string;
+ cacheControl?: string;
+ upsert?: boolean;
+ }
+): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return uploadToS3(bucket, path, file, options);
+ }
+
+ // Fall back to Supabase Storage
+ try {
+ const { data, error } = await supabaseAdmin.storage.from(bucket).upload(path, file, {
+ contentType: options?.contentType,
+ cacheControl: options?.cacheControl || '3600',
+ upsert: options?.upsert || false
+ });
+
+ if (error) {
+ console.error('Storage upload error:', error);
+ return { success: false, error: error.message };
+ }
+
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ const publicUrl = getBrowserAccessibleUrl(bucket, path);
+
+ return {
+ success: true,
+ path: data.path,
+ publicUrl
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Storage upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Get the public URL for a file in storage
+ */
+export async function getPublicUrl(bucket: StorageBucket, path: string): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ const config = await getS3Config();
+ if (config) {
+ const key = getS3Key(bucket, path);
+ if (config.forcePathStyle) {
+ return `${config.endpoint}/${config.bucket}/${key}`;
+ }
+ const protocol = config.useSSL ? 'https' : 'http';
+ return `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
+ }
+ }
+
+ // Fall back to Supabase Storage - use browser-accessible URL
+ return getBrowserAccessibleUrl(bucket, path);
+}
+
+/**
+ * Get a signed URL for private file access
+ */
+export async function getSignedUrl(
+ bucket: StorageBucket,
+ path: string,
+ expiresIn: number = 3600
+): Promise<{ url: string | null; error: string | null }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return getS3PresignedUrl(bucket, path, expiresIn);
+ }
+
+ // Fall back to Supabase Storage
+ const { data, error } = await supabaseAdmin.storage
+ .from(bucket)
+ .createSignedUrl(path, expiresIn);
+
+ if (error) {
+ return { url: null, error: error.message };
+ }
+
+ return { url: data.signedUrl, error: null };
+}
+
+/**
+ * Delete a file from storage
+ */
+export async function deleteFile(
+ bucket: StorageBucket,
+ path: string
+): Promise<{ success: boolean; error?: string }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return deleteFromS3(bucket, path);
+ }
+
+ // Fall back to Supabase Storage
+ const { error } = await supabaseAdmin.storage.from(bucket).remove([path]);
+
+ if (error) {
+ console.error('Storage delete error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Delete multiple files from storage
+ */
+export async function deleteFiles(
+ bucket: StorageBucket,
+ paths: string[]
+): Promise<{ success: boolean; error?: string }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return deleteMultipleFromS3(bucket, paths);
+ }
+
+ // Fall back to Supabase Storage
+ const { error } = await supabaseAdmin.storage.from(bucket).remove(paths);
+
+ if (error) {
+ console.error('Storage delete error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+}
+
+/**
+ * List files in a bucket/folder
+ */
+export async function listFiles(
+ bucket: StorageBucket,
+ folder?: string,
+ options?: {
+ limit?: number;
+ offset?: number;
+ sortBy?: { column: string; order: 'asc' | 'desc' };
+ }
+): Promise<{ files: any[]; error: string | null }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return listFilesFromS3(bucket, folder, options);
+ }
+
+ // Fall back to Supabase Storage
+ const { data, error } = await supabaseAdmin.storage.from(bucket).list(folder || '', {
+ limit: options?.limit || 100,
+ offset: options?.offset || 0,
+ sortBy: options?.sortBy || { column: 'created_at', order: 'desc' }
+ });
+
+ if (error) {
+ return { files: [], error: error.message };
+ }
+
+ return { files: data || [], error: null };
+}
+
+/**
+ * Generate a unique filename with timestamp
+ */
+export function generateUniqueFilename(originalName: string): string {
+ const timestamp = Date.now();
+ const randomStr = Math.random().toString(36).substring(2, 8);
+ const safeName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
+ const ext = safeName.split('.').pop() || '';
+ const nameWithoutExt = safeName.replace(`.${ext}`, '');
+ return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`;
+}
+
+/**
+ * Upload an avatar image for a member
+ * Returns both S3 and local URLs for storage flexibility
+ */
+export async function uploadAvatar(
+ memberId: string,
+ file: File,
+ userSupabase?: ReturnType
+): Promise {
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+ if (!allowedTypes.includes(file.type)) {
+ return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF' };
+ }
+
+ // Validate file size (max 5MB)
+ const maxSize = 5 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'Image size must be less than 5MB' };
+ }
+
+ // Generate path - memberId must match auth.uid() for RLS
+ const ext = file.name.split('.').pop() || 'jpg';
+ const path = `${memberId}/avatar.${ext}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Check if S3 is enabled
+ const s3Enabled = await isS3Enabled();
+
+ // Result object
+ const result: UploadResult = {
+ success: false,
+ path
+ };
+
+ // Upload to S3 if enabled
+ if (s3Enabled) {
+ const s3Result = await uploadToS3('avatars', path, arrayBuffer, {
+ contentType: file.type
+ });
+
+ if (!s3Result.success) {
+ return s3Result;
+ }
+
+ result.s3Url = s3Result.publicUrl;
+ result.publicUrl = s3Result.publicUrl;
+ result.success = true;
+ }
+
+ // Always upload to Supabase Storage as well (for fallback)
+ try {
+ // First try to delete existing avatar (ignore errors)
+ await supabaseAdmin.storage.from('avatars').remove([path]);
+
+ const { data, error } = await supabaseAdmin.storage.from('avatars').upload(path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: true
+ });
+
+ if (error) {
+ // If S3 succeeded, this is okay - just log
+ if (result.success) {
+ console.warn('Local storage upload failed (S3 succeeded):', error);
+ } else {
+ console.error('Avatar upload error:', error);
+ return { success: false, error: error.message };
+ }
+ } else {
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ result.localUrl = getBrowserAccessibleUrl('avatars', path);
+
+ // If S3 is not enabled, use local URL as the public URL
+ if (!s3Enabled) {
+ result.publicUrl = result.localUrl;
+ result.success = true;
+ }
+ }
+ } catch (error) {
+ // If S3 succeeded, this is okay
+ if (!result.success) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Avatar upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Delete a member's avatar from ALL storage backends
+ * Always attempts to delete from both S3 and Supabase Storage
+ */
+export async function deleteAvatar(
+ memberId: string,
+ avatarPath?: string
+): Promise<{ success: boolean; error?: string }> {
+ // If we have a specific path, use it; otherwise try common extensions
+ let paths: string[];
+ if (avatarPath) {
+ paths = [avatarPath];
+ } else {
+ const extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
+ paths = extensions.map((ext) => `${memberId}/avatar.${ext}`);
+ }
+
+ const errors: string[] = [];
+
+ // Always try to delete from S3 (in case it was uploaded when S3 was enabled)
+ try {
+ const s3Config = await getS3Config();
+ if (s3Config) {
+ const result = await deleteMultipleFromS3('avatars', paths);
+ if (!result.success && result.error) {
+ console.warn('S3 avatar delete warning:', result.error);
+ }
+ }
+ } catch (error) {
+ console.warn('S3 avatar delete error (non-critical):', error);
+ }
+
+ // Always try to delete from Supabase Storage
+ try {
+ await supabaseAdmin.storage.from('avatars').remove(paths);
+ } catch (error) {
+ console.warn('Local storage avatar delete error (non-critical):', error);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get the appropriate avatar URL based on current storage settings
+ * Useful for getting the right URL when storage setting is toggled
+ */
+export async function getActiveAvatarUrl(member: {
+ avatar_url_s3?: string | null;
+ avatar_url_local?: string | null;
+ avatar_url?: string | null;
+}): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return member.avatar_url_s3 || member.avatar_url || null;
+ }
+ return member.avatar_url_local || member.avatar_url || null;
+}
+
+/**
+ * Upload a document to storage
+ * Returns both S3 and local URLs for storage flexibility (same pattern as avatars)
+ */
+export async function uploadDocument(
+ file: File,
+ options?: {
+ folder?: string;
+ }
+): Promise {
+ // Validate file type
+ const allowedTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'text/plain',
+ 'text/csv',
+ 'application/json',
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif'
+ ];
+
+ if (!allowedTypes.includes(file.type)) {
+ return {
+ success: false,
+ error:
+ 'File type not allowed. Supported: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, WebP, GIF'
+ };
+ }
+
+ // Validate file size (max 50MB)
+ const maxSize = 50 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'File size must be less than 50MB' };
+ }
+
+ // Generate unique storage path
+ const timestamp = Date.now();
+ const randomStr = Math.random().toString(36).substring(2, 8);
+ const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
+ const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Check if S3 is enabled
+ const s3Enabled = await isS3Enabled();
+
+ // Result object
+ const result: UploadResult = {
+ success: false,
+ path
+ };
+
+ // Upload to S3 if enabled
+ if (s3Enabled) {
+ const s3Result = await uploadToS3('documents', path, arrayBuffer, {
+ contentType: file.type
+ });
+
+ if (!s3Result.success) {
+ return s3Result;
+ }
+
+ result.s3Url = s3Result.publicUrl;
+ result.publicUrl = s3Result.publicUrl;
+ result.success = true;
+ }
+
+ // Always upload to Supabase Storage as well (for fallback)
+ try {
+ const { data, error } = await supabaseAdmin.storage.from('documents').upload(path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: false
+ });
+
+ if (error) {
+ // If S3 succeeded, this is okay - just log
+ if (result.success) {
+ console.warn('Local storage upload failed (S3 succeeded):', error);
+ } else {
+ console.error('Document upload error:', error);
+ return { success: false, error: error.message };
+ }
+ } else {
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ result.localUrl = getBrowserAccessibleUrl('documents', path);
+
+ // If S3 is not enabled, use local URL as the public URL
+ if (!s3Enabled) {
+ result.publicUrl = result.localUrl;
+ result.success = true;
+ }
+ }
+ } catch (error) {
+ // If S3 succeeded, this is okay
+ if (!result.success) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Document upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Delete a document from ALL storage backends
+ * Always attempts to delete from both S3 and Supabase Storage
+ */
+export async function deleteDocument(
+ storagePath: string
+): Promise<{ success: boolean; error?: string }> {
+ const errors: string[] = [];
+
+ // Always try to delete from S3 (in case it was uploaded when S3 was enabled)
+ try {
+ const s3Config = await getS3Config();
+ if (s3Config) {
+ const result = await deleteFromS3('documents', storagePath);
+ if (!result.success && result.error) {
+ console.warn('S3 document delete warning:', result.error);
+ }
+ }
+ } catch (error) {
+ console.warn('S3 document delete error (non-critical):', error);
+ }
+
+ // Always try to delete from Supabase Storage
+ try {
+ await supabaseAdmin.storage.from('documents').remove([storagePath]);
+ } catch (error) {
+ console.warn('Local storage document delete error (non-critical):', error);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get the appropriate document URL based on current storage settings
+ * Useful for getting the right URL when storage setting is toggled
+ */
+export async function getActiveDocumentUrl(document: {
+ file_url_s3?: string | null;
+ file_url_local?: string | null;
+ file_path?: string | null;
+}): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return document.file_url_s3 || document.file_path || null;
+ }
+ return document.file_url_local || document.file_path || null;
+}
+
+/**
+ * Upload an event cover image
+ */
+export async function uploadEventImage(eventId: string, file: File): Promise {
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
+ if (!allowedTypes.includes(file.type)) {
+ return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP' };
+ }
+
+ // Validate file size (max 10MB)
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'Image size must be less than 10MB' };
+ }
+
+ // Generate path
+ const ext = file.name.split('.').pop() || 'jpg';
+ const path = `${eventId}/cover.${ext}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Upload with upsert to replace existing cover
+ return uploadFile('event-images', path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: true
+ });
+}
diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts
new file mode 100644
index 0000000..72136e1
--- /dev/null
+++ b/src/lib/server/supabase.ts
@@ -0,0 +1,42 @@
+import pkg from '@supabase/ssr';
+const { createServerClient } = pkg;
+import { createClient as createSupabaseClient } from '@supabase/supabase-js';
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
+import { env } from '$env/dynamic/private';
+import type { Cookies } from '@sveltejs/kit';
+import type { Database } from '$lib/types/database';
+
+// Use internal URL for server-side operations (Docker network), fallback to public URL
+const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL;
+
+/**
+ * Create a Supabase client for server-side operations with cookie handling
+ */
+export function createSupabaseServerClient(cookies: Cookies) {
+ return createServerClient(SERVER_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
+ cookies: {
+ getAll: () => cookies.getAll(),
+ setAll: (cookiesToSet) => {
+ cookiesToSet.forEach(({ name, value, options }) => {
+ cookies.set(name, value, { ...options, path: '/' });
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Supabase Admin client with service role key
+ * Use this for administrative operations that bypass RLS
+ */
+export const supabaseAdmin = createSupabaseClient(
+ SERVER_SUPABASE_URL,
+ SUPABASE_SERVICE_ROLE_KEY,
+ {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false
+ }
+ }
+);
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
new file mode 100644
index 0000000..0d101a0
--- /dev/null
+++ b/src/lib/supabase.ts
@@ -0,0 +1,11 @@
+import pkg from '@supabase/ssr';
+const { createBrowserClient } = pkg;
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import type { Database } from './types/database';
+
+/**
+ * Create a Supabase client for browser-side operations
+ */
+export function createClient() {
+ return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
+}
diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts
new file mode 100644
index 0000000..6535339
--- /dev/null
+++ b/src/lib/types/database.ts
@@ -0,0 +1,806 @@
+/**
+ * Database Types for Monaco USA Portal 2026
+ * Generated based on the architecture plan schema
+ */
+
+export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
+
+export type Database = {
+ public: {
+ Tables: {
+ members: {
+ Row: {
+ id: string;
+ member_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role: 'member' | 'board' | 'admin';
+ membership_status_id: string | null;
+ membership_type_id: string | null;
+ member_since: string;
+ avatar_url: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id: string;
+ member_id?: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role?: 'member' | 'board' | 'admin';
+ membership_status_id?: string | null;
+ membership_type_id?: string | null;
+ member_since?: string;
+ avatar_url?: string | null;
+ notes?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ first_name?: string;
+ last_name?: string;
+ email?: string;
+ phone?: string;
+ date_of_birth?: string;
+ address?: string;
+ nationality?: string[];
+ role?: 'member' | 'board' | 'admin';
+ membership_status_id?: string | null;
+ membership_type_id?: string | null;
+ member_since?: string;
+ avatar_url?: string | null;
+ notes?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ membership_statuses: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ color: string;
+ description: string | null;
+ is_default: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ color?: string;
+ description?: string | null;
+ is_default?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ color?: string;
+ description?: string | null;
+ is_default?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ membership_types: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ annual_dues: number;
+ description: string | null;
+ is_default: boolean;
+ is_active: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ annual_dues: number;
+ description?: string | null;
+ is_default?: boolean;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ annual_dues?: number;
+ description?: string | null;
+ is_default?: boolean;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ dues_payments: {
+ Row: {
+ id: string;
+ member_id: string;
+ amount: number;
+ currency: string;
+ payment_date: string;
+ due_date: string;
+ payment_method: string;
+ reference: string | null;
+ notes: string | null;
+ recorded_by: string;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ member_id: string;
+ amount: number;
+ currency?: string;
+ payment_date: string;
+ due_date?: string;
+ payment_method?: string;
+ reference?: string | null;
+ notes?: string | null;
+ recorded_by: string;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ amount?: number;
+ currency?: string;
+ payment_date?: string;
+ due_date?: string;
+ payment_method?: string;
+ reference?: string | null;
+ notes?: string | null;
+ recorded_by?: string;
+ created_at?: string;
+ };
+ };
+ events: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ event_type_id: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day: boolean;
+ timezone: string;
+ location: string | null;
+ location_url: string | null;
+ max_attendees: number | null;
+ max_guests_per_member: number;
+ is_paid: boolean;
+ member_price: number;
+ non_member_price: number;
+ pricing_notes: string | null;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ status: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url: string | null;
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ title: string;
+ description?: string | null;
+ event_type_id?: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day?: boolean;
+ timezone?: string;
+ location?: string | null;
+ location_url?: string | null;
+ max_attendees?: number | null;
+ max_guests_per_member?: number;
+ is_paid?: boolean;
+ member_price?: number;
+ non_member_price?: number;
+ pricing_notes?: string | null;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ status?: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url?: string | null;
+ created_by: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ title?: string;
+ description?: string | null;
+ event_type_id?: string | null;
+ start_datetime?: string;
+ end_datetime?: string;
+ all_day?: boolean;
+ timezone?: string;
+ location?: string | null;
+ location_url?: string | null;
+ max_attendees?: number | null;
+ max_guests_per_member?: number;
+ is_paid?: boolean;
+ member_price?: number;
+ non_member_price?: number;
+ pricing_notes?: string | null;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ status?: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url?: string | null;
+ created_by?: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ event_types: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ color: string;
+ icon: string | null;
+ description: string | null;
+ is_active: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ color?: string;
+ icon?: string | null;
+ description?: string | null;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ color?: string;
+ icon?: string | null;
+ description?: string | null;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ event_rsvps: {
+ Row: {
+ id: string;
+ event_id: string;
+ member_id: string;
+ status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count: number;
+ guest_names: string[] | null;
+ notes: string | null;
+ payment_status: 'not_required' | 'pending' | 'paid';
+ payment_reference: string | null;
+ payment_amount: number | null;
+ attended: boolean;
+ checked_in_at: string | null;
+ checked_in_by: string | null;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ event_id: string;
+ member_id: string;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ notes?: string | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ checked_in_at?: string | null;
+ checked_in_by?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ event_id?: string;
+ member_id?: string;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ notes?: string | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ checked_in_at?: string | null;
+ checked_in_by?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ event_rsvps_public: {
+ Row: {
+ id: string;
+ event_id: string;
+ full_name: string;
+ email: string;
+ phone: string | null;
+ status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count: number;
+ guest_names: string[] | null;
+ payment_status: 'not_required' | 'pending' | 'paid';
+ payment_reference: string | null;
+ payment_amount: number | null;
+ attended: boolean;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ event_id: string;
+ full_name: string;
+ email: string;
+ phone?: string | null;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ event_id?: string;
+ full_name?: string;
+ email?: string;
+ phone?: string | null;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ documents: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ category_id: string | null;
+ file_path: string;
+ file_name: string;
+ file_size: number;
+ mime_type: string;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids: string[] | null;
+ version: number;
+ replaces_document_id: string | null;
+ meeting_date: string | null;
+ meeting_attendees: string[] | null;
+ uploaded_by: string;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ title: string;
+ description?: string | null;
+ category_id?: string | null;
+ file_path: string;
+ file_name: string;
+ file_size: number;
+ mime_type: string;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids?: string[] | null;
+ version?: number;
+ replaces_document_id?: string | null;
+ meeting_date?: string | null;
+ meeting_attendees?: string[] | null;
+ uploaded_by: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ title?: string;
+ description?: string | null;
+ category_id?: string | null;
+ file_path?: string;
+ file_name?: string;
+ file_size?: number;
+ mime_type?: string;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids?: string[] | null;
+ version?: number;
+ replaces_document_id?: string | null;
+ meeting_date?: string | null;
+ meeting_attendees?: string[] | null;
+ uploaded_by?: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ document_categories: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ description: string | null;
+ icon: string | null;
+ sort_order: number;
+ is_active: boolean;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ description?: string | null;
+ icon?: string | null;
+ sort_order?: number;
+ is_active?: boolean;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ description?: string | null;
+ icon?: string | null;
+ sort_order?: number;
+ is_active?: boolean;
+ created_at?: string;
+ };
+ };
+ app_settings: {
+ Row: {
+ id: string;
+ category: string;
+ setting_key: string;
+ setting_value: Json;
+ setting_type: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name: string;
+ description: string | null;
+ is_public: boolean;
+ updated_at: string;
+ updated_by: string | null;
+ };
+ Insert: {
+ id?: string;
+ category: string;
+ setting_key: string;
+ setting_value: Json;
+ setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name: string;
+ description?: string | null;
+ is_public?: boolean;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ Update: {
+ id?: string;
+ category?: string;
+ setting_key?: string;
+ setting_value?: Json;
+ setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name?: string;
+ description?: string | null;
+ is_public?: boolean;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ };
+ email_templates: {
+ Row: {
+ id: string;
+ template_key: string;
+ template_name: string;
+ category: string;
+ subject: string;
+ body_html: string;
+ body_text: string | null;
+ is_active: boolean;
+ is_system: boolean;
+ variables_schema: Json | null;
+ preview_data: Json | null;
+ created_at: string;
+ updated_at: string;
+ updated_by: string | null;
+ };
+ Insert: {
+ id?: string;
+ template_key: string;
+ template_name: string;
+ category: string;
+ subject: string;
+ body_html: string;
+ body_text?: string | null;
+ is_active?: boolean;
+ is_system?: boolean;
+ variables_schema?: Json | null;
+ preview_data?: Json | null;
+ created_at?: string;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ Update: {
+ id?: string;
+ template_key?: string;
+ template_name?: string;
+ category?: string;
+ subject?: string;
+ body_html?: string;
+ body_text?: string | null;
+ is_active?: boolean;
+ is_system?: boolean;
+ variables_schema?: Json | null;
+ preview_data?: Json | null;
+ created_at?: string;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ };
+ email_logs: {
+ Row: {
+ id: string;
+ recipient_id: string | null;
+ recipient_email: string;
+ recipient_name: string | null;
+ template_key: string | null;
+ subject: string;
+ email_type: string;
+ status: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider: string | null;
+ provider_message_id: string | null;
+ opened_at: string | null;
+ clicked_at: string | null;
+ error_message: string | null;
+ retry_count: number;
+ template_variables: Json | null;
+ sent_by: string | null;
+ created_at: string;
+ sent_at: string | null;
+ delivered_at: string | null;
+ };
+ Insert: {
+ id?: string;
+ recipient_id?: string | null;
+ recipient_email: string;
+ recipient_name?: string | null;
+ template_key?: string | null;
+ subject: string;
+ email_type: string;
+ status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider?: string | null;
+ provider_message_id?: string | null;
+ opened_at?: string | null;
+ clicked_at?: string | null;
+ error_message?: string | null;
+ retry_count?: number;
+ template_variables?: Json | null;
+ sent_by?: string | null;
+ created_at?: string;
+ sent_at?: string | null;
+ delivered_at?: string | null;
+ };
+ Update: {
+ id?: string;
+ recipient_id?: string | null;
+ recipient_email?: string;
+ recipient_name?: string | null;
+ template_key?: string | null;
+ subject?: string;
+ email_type?: string;
+ status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider?: string | null;
+ provider_message_id?: string | null;
+ opened_at?: string | null;
+ clicked_at?: string | null;
+ error_message?: string | null;
+ retry_count?: number;
+ template_variables?: Json | null;
+ sent_by?: string | null;
+ created_at?: string;
+ sent_at?: string | null;
+ delivered_at?: string | null;
+ };
+ };
+ audit_logs: {
+ Row: {
+ id: string;
+ user_id: string | null;
+ user_email: string | null;
+ action: string;
+ resource_type: string | null;
+ resource_id: string | null;
+ details: Json;
+ ip_address: string | null;
+ user_agent: string | null;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ user_id?: string | null;
+ user_email?: string | null;
+ action: string;
+ resource_type?: string | null;
+ resource_id?: string | null;
+ details?: Json;
+ ip_address?: string | null;
+ user_agent?: string | null;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ user_id?: string | null;
+ user_email?: string | null;
+ action?: string;
+ resource_type?: string | null;
+ resource_id?: string | null;
+ details?: Json;
+ ip_address?: string | null;
+ user_agent?: string | null;
+ created_at?: string;
+ };
+ };
+ dues_reminder_logs: {
+ Row: {
+ id: string;
+ member_id: string;
+ reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date: string;
+ sent_at: string;
+ email_log_id: string | null;
+ };
+ Insert: {
+ id?: string;
+ member_id: string;
+ reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date: string;
+ sent_at?: string;
+ email_log_id?: string | null;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ reminder_type?: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date?: string;
+ sent_at?: string;
+ email_log_id?: string | null;
+ };
+ };
+ };
+ Views: {
+ members_with_dues: {
+ Row: {
+ id: string;
+ member_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role: 'member' | 'board' | 'admin';
+ membership_status_id: string | null;
+ membership_type_id: string | null;
+ member_since: string;
+ avatar_url: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ status_name: string | null;
+ status_display_name: string | null;
+ status_color: string | null;
+ membership_type_name: string | null;
+ annual_dues: number | null;
+ last_payment_date: string | null;
+ current_due_date: string | null;
+ dues_status: 'never_paid' | 'overdue' | 'due_soon' | 'current';
+ days_overdue: number | null;
+ days_until_due: number | null;
+ };
+ };
+ events_with_counts: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ event_type_id: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day: boolean;
+ timezone: string;
+ location: string | null;
+ location_url: string | null;
+ max_attendees: number | null;
+ max_guests_per_member: number;
+ is_paid: boolean;
+ member_price: number;
+ non_member_price: number;
+ pricing_notes: string | null;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ status: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url: string | null;
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ event_type_name: string | null;
+ event_type_color: string | null;
+ event_type_icon: string | null;
+ total_attendees: number;
+ member_count: number;
+ non_member_count: number;
+ waitlist_count: number;
+ is_full: boolean;
+ };
+ };
+ };
+ Functions: Record;
+ Enums: Record;
+ };
+};
+
+// Convenience types
+export type Member = Database['public']['Tables']['members']['Row'];
+export type MemberInsert = Database['public']['Tables']['members']['Insert'];
+export type MemberUpdate = Database['public']['Tables']['members']['Update'];
+
+export type MemberWithDues = Database['public']['Views']['members_with_dues']['Row'];
+
+export type Event = Database['public']['Tables']['events']['Row'];
+export type EventInsert = Database['public']['Tables']['events']['Insert'];
+export type EventUpdate = Database['public']['Tables']['events']['Update'];
+
+export type EventWithCounts = Database['public']['Views']['events_with_counts']['Row'];
+
+export type EventRSVP = Database['public']['Tables']['event_rsvps']['Row'];
+export type EventRSVPInsert = Database['public']['Tables']['event_rsvps']['Insert'];
+
+export type DuesPayment = Database['public']['Tables']['dues_payments']['Row'];
+export type DuesPaymentInsert = Database['public']['Tables']['dues_payments']['Insert'];
+
+export type Document = Database['public']['Tables']['documents']['Row'];
+export type DocumentInsert = Database['public']['Tables']['documents']['Insert'];
+
+export type AppSetting = Database['public']['Tables']['app_settings']['Row'];
+export type EmailTemplate = Database['public']['Tables']['email_templates']['Row'];
+export type EmailLog = Database['public']['Tables']['email_logs']['Row'];
+export type AuditLog = Database['public']['Tables']['audit_logs']['Row'];
+export type DuesReminderLog = Database['public']['Tables']['dues_reminder_logs']['Row'];
+
+// Role type
+export type MemberRole = 'member' | 'board' | 'admin';
+
+// Event visibility
+export type EventVisibility = 'public' | 'members' | 'board' | 'admin';
+
+// Dues status
+export type DuesStatus = 'never_paid' | 'overdue' | 'due_soon' | 'current';
diff --git a/src/lib/utils/countries.ts b/src/lib/utils/countries.ts
new file mode 100644
index 0000000..0db4d26
--- /dev/null
+++ b/src/lib/utils/countries.ts
@@ -0,0 +1,262 @@
+// Complete ISO 3166-1 alpha-2 country codes with names and flags
+export const countries = [
+ { code: 'AF', name: 'Afghanistan', flag: '🇦🇫' },
+ { code: 'AL', name: 'Albania', flag: '🇦🇱' },
+ { code: 'DZ', name: 'Algeria', flag: '🇩🇿' },
+ { code: 'AS', name: 'American Samoa', flag: '🇦🇸' },
+ { code: 'AD', name: 'Andorra', flag: '🇦🇩' },
+ { code: 'AO', name: 'Angola', flag: '🇦🇴' },
+ { code: 'AI', name: 'Anguilla', flag: '🇦🇮' },
+ { code: 'AQ', name: 'Antarctica', flag: '🇦🇶' },
+ { code: 'AG', name: 'Antigua and Barbuda', flag: '🇦🇬' },
+ { code: 'AR', name: 'Argentina', flag: '🇦🇷' },
+ { code: 'AM', name: 'Armenia', flag: '🇦🇲' },
+ { code: 'AW', name: 'Aruba', flag: '🇦🇼' },
+ { code: 'AU', name: 'Australia', flag: '🇦🇺' },
+ { code: 'AT', name: 'Austria', flag: '🇦🇹' },
+ { code: 'AZ', name: 'Azerbaijan', flag: '🇦🇿' },
+ { code: 'BS', name: 'Bahamas', flag: '🇧🇸' },
+ { code: 'BH', name: 'Bahrain', flag: '🇧🇭' },
+ { code: 'BD', name: 'Bangladesh', flag: '🇧🇩' },
+ { code: 'BB', name: 'Barbados', flag: '🇧🇧' },
+ { code: 'BY', name: 'Belarus', flag: '🇧🇾' },
+ { code: 'BE', name: 'Belgium', flag: '🇧🇪' },
+ { code: 'BZ', name: 'Belize', flag: '🇧🇿' },
+ { code: 'BJ', name: 'Benin', flag: '🇧🇯' },
+ { code: 'BM', name: 'Bermuda', flag: '🇧🇲' },
+ { code: 'BT', name: 'Bhutan', flag: '🇧🇹' },
+ { code: 'BO', name: 'Bolivia', flag: '🇧🇴' },
+ { code: 'BA', name: 'Bosnia and Herzegovina', flag: '🇧🇦' },
+ { code: 'BW', name: 'Botswana', flag: '🇧🇼' },
+ { code: 'BR', name: 'Brazil', flag: '🇧🇷' },
+ { code: 'IO', name: 'British Indian Ocean Territory', flag: '🇮🇴' },
+ { code: 'VG', name: 'British Virgin Islands', flag: '🇻🇬' },
+ { code: 'BN', name: 'Brunei', flag: '🇧🇳' },
+ { code: 'BG', name: 'Bulgaria', flag: '🇧🇬' },
+ { code: 'BF', name: 'Burkina Faso', flag: '🇧🇫' },
+ { code: 'BI', name: 'Burundi', flag: '🇧🇮' },
+ { code: 'CV', name: 'Cabo Verde', flag: '🇨🇻' },
+ { code: 'KH', name: 'Cambodia', flag: '🇰🇭' },
+ { code: 'CM', name: 'Cameroon', flag: '🇨🇲' },
+ { code: 'CA', name: 'Canada', flag: '🇨🇦' },
+ { code: 'KY', name: 'Cayman Islands', flag: '🇰🇾' },
+ { code: 'CF', name: 'Central African Republic', flag: '🇨🇫' },
+ { code: 'TD', name: 'Chad', flag: '🇹🇩' },
+ { code: 'CL', name: 'Chile', flag: '🇨🇱' },
+ { code: 'CN', name: 'China', flag: '🇨🇳' },
+ { code: 'CX', name: 'Christmas Island', flag: '🇨🇽' },
+ { code: 'CC', name: 'Cocos (Keeling) Islands', flag: '🇨🇨' },
+ { code: 'CO', name: 'Colombia', flag: '🇨🇴' },
+ { code: 'KM', name: 'Comoros', flag: '🇰🇲' },
+ { code: 'CG', name: 'Congo', flag: '🇨🇬' },
+ { code: 'CD', name: 'Congo (DRC)', flag: '🇨🇩' },
+ { code: 'CK', name: 'Cook Islands', flag: '🇨🇰' },
+ { code: 'CR', name: 'Costa Rica', flag: '🇨🇷' },
+ { code: 'CI', name: "Côte d'Ivoire", flag: '🇨🇮' },
+ { code: 'HR', name: 'Croatia', flag: '🇭🇷' },
+ { code: 'CU', name: 'Cuba', flag: '🇨🇺' },
+ { code: 'CW', name: 'Curaçao', flag: '🇨🇼' },
+ { code: 'CY', name: 'Cyprus', flag: '🇨🇾' },
+ { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' },
+ { code: 'DK', name: 'Denmark', flag: '🇩🇰' },
+ { code: 'DJ', name: 'Djibouti', flag: '🇩🇯' },
+ { code: 'DM', name: 'Dominica', flag: '🇩🇲' },
+ { code: 'DO', name: 'Dominican Republic', flag: '🇩🇴' },
+ { code: 'EC', name: 'Ecuador', flag: '🇪🇨' },
+ { code: 'EG', name: 'Egypt', flag: '🇪🇬' },
+ { code: 'SV', name: 'El Salvador', flag: '🇸🇻' },
+ { code: 'GQ', name: 'Equatorial Guinea', flag: '🇬🇶' },
+ { code: 'ER', name: 'Eritrea', flag: '🇪🇷' },
+ { code: 'EE', name: 'Estonia', flag: '🇪🇪' },
+ { code: 'SZ', name: 'Eswatini', flag: '🇸🇿' },
+ { code: 'ET', name: 'Ethiopia', flag: '🇪🇹' },
+ { code: 'FK', name: 'Falkland Islands', flag: '🇫🇰' },
+ { code: 'FO', name: 'Faroe Islands', flag: '🇫🇴' },
+ { code: 'FJ', name: 'Fiji', flag: '🇫🇯' },
+ { code: 'FI', name: 'Finland', flag: '🇫🇮' },
+ { code: 'FR', name: 'France', flag: '🇫🇷' },
+ { code: 'GF', name: 'French Guiana', flag: '🇬🇫' },
+ { code: 'PF', name: 'French Polynesia', flag: '🇵🇫' },
+ { code: 'TF', name: 'French Southern Territories', flag: '🇹🇫' },
+ { code: 'GA', name: 'Gabon', flag: '🇬🇦' },
+ { code: 'GM', name: 'Gambia', flag: '🇬🇲' },
+ { code: 'GE', name: 'Georgia', flag: '🇬🇪' },
+ { code: 'DE', name: 'Germany', flag: '🇩🇪' },
+ { code: 'GH', name: 'Ghana', flag: '🇬🇭' },
+ { code: 'GI', name: 'Gibraltar', flag: '🇬🇮' },
+ { code: 'GR', name: 'Greece', flag: '🇬🇷' },
+ { code: 'GL', name: 'Greenland', flag: '🇬🇱' },
+ { code: 'GD', name: 'Grenada', flag: '🇬🇩' },
+ { code: 'GP', name: 'Guadeloupe', flag: '🇬🇵' },
+ { code: 'GU', name: 'Guam', flag: '🇬🇺' },
+ { code: 'GT', name: 'Guatemala', flag: '🇬🇹' },
+ { code: 'GG', name: 'Guernsey', flag: '🇬🇬' },
+ { code: 'GN', name: 'Guinea', flag: '🇬🇳' },
+ { code: 'GW', name: 'Guinea-Bissau', flag: '🇬🇼' },
+ { code: 'GY', name: 'Guyana', flag: '🇬🇾' },
+ { code: 'HT', name: 'Haiti', flag: '🇭🇹' },
+ { code: 'HN', name: 'Honduras', flag: '🇭🇳' },
+ { code: 'HK', name: 'Hong Kong', flag: '🇭🇰' },
+ { code: 'HU', name: 'Hungary', flag: '🇭🇺' },
+ { code: 'IS', name: 'Iceland', flag: '🇮🇸' },
+ { code: 'IN', name: 'India', flag: '🇮🇳' },
+ { code: 'ID', name: 'Indonesia', flag: '🇮🇩' },
+ { code: 'IR', name: 'Iran', flag: '🇮🇷' },
+ { code: 'IQ', name: 'Iraq', flag: '🇮🇶' },
+ { code: 'IE', name: 'Ireland', flag: '🇮🇪' },
+ { code: 'IM', name: 'Isle of Man', flag: '🇮🇲' },
+ { code: 'IL', name: 'Israel', flag: '🇮🇱' },
+ { code: 'IT', name: 'Italy', flag: '🇮🇹' },
+ { code: 'JM', name: 'Jamaica', flag: '🇯🇲' },
+ { code: 'JP', name: 'Japan', flag: '🇯🇵' },
+ { code: 'JE', name: 'Jersey', flag: '🇯🇪' },
+ { code: 'JO', name: 'Jordan', flag: '🇯🇴' },
+ { code: 'KZ', name: 'Kazakhstan', flag: '🇰🇿' },
+ { code: 'KE', name: 'Kenya', flag: '🇰🇪' },
+ { code: 'KI', name: 'Kiribati', flag: '🇰🇮' },
+ { code: 'KW', name: 'Kuwait', flag: '🇰🇼' },
+ { code: 'KG', name: 'Kyrgyzstan', flag: '🇰🇬' },
+ { code: 'LA', name: 'Laos', flag: '🇱🇦' },
+ { code: 'LV', name: 'Latvia', flag: '🇱🇻' },
+ { code: 'LB', name: 'Lebanon', flag: '🇱🇧' },
+ { code: 'LS', name: 'Lesotho', flag: '🇱🇸' },
+ { code: 'LR', name: 'Liberia', flag: '🇱🇷' },
+ { code: 'LY', name: 'Libya', flag: '🇱🇾' },
+ { code: 'LI', name: 'Liechtenstein', flag: '🇱🇮' },
+ { code: 'LT', name: 'Lithuania', flag: '🇱🇹' },
+ { code: 'LU', name: 'Luxembourg', flag: '🇱🇺' },
+ { code: 'MO', name: 'Macao', flag: '🇲🇴' },
+ { code: 'MG', name: 'Madagascar', flag: '🇲🇬' },
+ { code: 'MW', name: 'Malawi', flag: '🇲🇼' },
+ { code: 'MY', name: 'Malaysia', flag: '🇲🇾' },
+ { code: 'MV', name: 'Maldives', flag: '🇲🇻' },
+ { code: 'ML', name: 'Mali', flag: '🇲🇱' },
+ { code: 'MT', name: 'Malta', flag: '🇲🇹' },
+ { code: 'MH', name: 'Marshall Islands', flag: '🇲🇭' },
+ { code: 'MQ', name: 'Martinique', flag: '🇲🇶' },
+ { code: 'MR', name: 'Mauritania', flag: '🇲🇷' },
+ { code: 'MU', name: 'Mauritius', flag: '🇲🇺' },
+ { code: 'YT', name: 'Mayotte', flag: '🇾🇹' },
+ { code: 'MX', name: 'Mexico', flag: '🇲🇽' },
+ { code: 'FM', name: 'Micronesia', flag: '🇫🇲' },
+ { code: 'MD', name: 'Moldova', flag: '🇲🇩' },
+ { code: 'MC', name: 'Monaco', flag: '🇲🇨' },
+ { code: 'MN', name: 'Mongolia', flag: '🇲🇳' },
+ { code: 'ME', name: 'Montenegro', flag: '🇲🇪' },
+ { code: 'MS', name: 'Montserrat', flag: '🇲🇸' },
+ { code: 'MA', name: 'Morocco', flag: '🇲🇦' },
+ { code: 'MZ', name: 'Mozambique', flag: '🇲🇿' },
+ { code: 'MM', name: 'Myanmar', flag: '🇲🇲' },
+ { code: 'NA', name: 'Namibia', flag: '🇳🇦' },
+ { code: 'NR', name: 'Nauru', flag: '🇳🇷' },
+ { code: 'NP', name: 'Nepal', flag: '🇳🇵' },
+ { code: 'NL', name: 'Netherlands', flag: '🇳🇱' },
+ { code: 'NC', name: 'New Caledonia', flag: '🇳🇨' },
+ { code: 'NZ', name: 'New Zealand', flag: '🇳🇿' },
+ { code: 'NI', name: 'Nicaragua', flag: '🇳🇮' },
+ { code: 'NE', name: 'Niger', flag: '🇳🇪' },
+ { code: 'NG', name: 'Nigeria', flag: '🇳🇬' },
+ { code: 'NU', name: 'Niue', flag: '🇳🇺' },
+ { code: 'NF', name: 'Norfolk Island', flag: '🇳🇫' },
+ { code: 'KP', name: 'North Korea', flag: '🇰🇵' },
+ { code: 'MK', name: 'North Macedonia', flag: '🇲🇰' },
+ { code: 'MP', name: 'Northern Mariana Islands', flag: '🇲🇵' },
+ { code: 'NO', name: 'Norway', flag: '🇳🇴' },
+ { code: 'OM', name: 'Oman', flag: '🇴🇲' },
+ { code: 'PK', name: 'Pakistan', flag: '🇵🇰' },
+ { code: 'PW', name: 'Palau', flag: '🇵🇼' },
+ { code: 'PS', name: 'Palestine', flag: '🇵🇸' },
+ { code: 'PA', name: 'Panama', flag: '🇵🇦' },
+ { code: 'PG', name: 'Papua New Guinea', flag: '🇵🇬' },
+ { code: 'PY', name: 'Paraguay', flag: '🇵🇾' },
+ { code: 'PE', name: 'Peru', flag: '🇵🇪' },
+ { code: 'PH', name: 'Philippines', flag: '🇵🇭' },
+ { code: 'PN', name: 'Pitcairn Islands', flag: '🇵🇳' },
+ { code: 'PL', name: 'Poland', flag: '🇵🇱' },
+ { code: 'PT', name: 'Portugal', flag: '🇵🇹' },
+ { code: 'PR', name: 'Puerto Rico', flag: '🇵🇷' },
+ { code: 'QA', name: 'Qatar', flag: '🇶🇦' },
+ { code: 'RE', name: 'Réunion', flag: '🇷🇪' },
+ { code: 'RO', name: 'Romania', flag: '🇷🇴' },
+ { code: 'RU', name: 'Russia', flag: '🇷🇺' },
+ { code: 'RW', name: 'Rwanda', flag: '🇷🇼' },
+ { code: 'BL', name: 'Saint Barthélemy', flag: '🇧🇱' },
+ { code: 'SH', name: 'Saint Helena', flag: '🇸🇭' },
+ { code: 'KN', name: 'Saint Kitts and Nevis', flag: '🇰🇳' },
+ { code: 'LC', name: 'Saint Lucia', flag: '🇱🇨' },
+ { code: 'MF', name: 'Saint Martin', flag: '🇲🇫' },
+ { code: 'PM', name: 'Saint Pierre and Miquelon', flag: '🇵🇲' },
+ { code: 'VC', name: 'Saint Vincent and the Grenadines', flag: '🇻🇨' },
+ { code: 'WS', name: 'Samoa', flag: '🇼🇸' },
+ { code: 'SM', name: 'San Marino', flag: '🇸🇲' },
+ { code: 'ST', name: 'São Tomé and Príncipe', flag: '🇸🇹' },
+ { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' },
+ { code: 'SN', name: 'Senegal', flag: '🇸🇳' },
+ { code: 'RS', name: 'Serbia', flag: '🇷🇸' },
+ { code: 'SC', name: 'Seychelles', flag: '🇸🇨' },
+ { code: 'SL', name: 'Sierra Leone', flag: '🇸🇱' },
+ { code: 'SG', name: 'Singapore', flag: '🇸🇬' },
+ { code: 'SX', name: 'Sint Maarten', flag: '🇸🇽' },
+ { code: 'SK', name: 'Slovakia', flag: '🇸🇰' },
+ { code: 'SI', name: 'Slovenia', flag: '🇸🇮' },
+ { code: 'SB', name: 'Solomon Islands', flag: '🇸🇧' },
+ { code: 'SO', name: 'Somalia', flag: '🇸🇴' },
+ { code: 'ZA', name: 'South Africa', flag: '🇿🇦' },
+ { code: 'GS', name: 'South Georgia and the South Sandwich Islands', flag: '🇬🇸' },
+ { code: 'KR', name: 'South Korea', flag: '🇰🇷' },
+ { code: 'SS', name: 'South Sudan', flag: '🇸🇸' },
+ { code: 'ES', name: 'Spain', flag: '🇪🇸' },
+ { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰' },
+ { code: 'SD', name: 'Sudan', flag: '🇸🇩' },
+ { code: 'SR', name: 'Suriname', flag: '🇸🇷' },
+ { code: 'SJ', name: 'Svalbard and Jan Mayen', flag: '🇸🇯' },
+ { code: 'SE', name: 'Sweden', flag: '🇸🇪' },
+ { code: 'CH', name: 'Switzerland', flag: '🇨🇭' },
+ { code: 'SY', name: 'Syria', flag: '🇸🇾' },
+ { code: 'TW', name: 'Taiwan', flag: '🇹🇼' },
+ { code: 'TJ', name: 'Tajikistan', flag: '🇹🇯' },
+ { code: 'TZ', name: 'Tanzania', flag: '🇹🇿' },
+ { code: 'TH', name: 'Thailand', flag: '🇹🇭' },
+ { code: 'TL', name: 'Timor-Leste', flag: '🇹🇱' },
+ { code: 'TG', name: 'Togo', flag: '🇹🇬' },
+ { code: 'TK', name: 'Tokelau', flag: '🇹🇰' },
+ { code: 'TO', name: 'Tonga', flag: '🇹🇴' },
+ { code: 'TT', name: 'Trinidad and Tobago', flag: '🇹🇹' },
+ { code: 'TN', name: 'Tunisia', flag: '🇹🇳' },
+ { code: 'TR', name: 'Turkey', flag: '🇹🇷' },
+ { code: 'TM', name: 'Turkmenistan', flag: '🇹🇲' },
+ { code: 'TC', name: 'Turks and Caicos Islands', flag: '🇹🇨' },
+ { code: 'TV', name: 'Tuvalu', flag: '🇹🇻' },
+ { code: 'UG', name: 'Uganda', flag: '🇺🇬' },
+ { code: 'UA', name: 'Ukraine', flag: '🇺🇦' },
+ { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' },
+ { code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
+ { code: 'US', name: 'United States', flag: '🇺🇸' },
+ { code: 'UM', name: 'United States Minor Outlying Islands', flag: '🇺🇲' },
+ { code: 'VI', name: 'United States Virgin Islands', flag: '🇻🇮' },
+ { code: 'UY', name: 'Uruguay', flag: '🇺🇾' },
+ { code: 'UZ', name: 'Uzbekistan', flag: '🇺🇿' },
+ { code: 'VU', name: 'Vanuatu', flag: '🇻🇺' },
+ { code: 'VA', name: 'Vatican City', flag: '🇻🇦' },
+ { code: 'VE', name: 'Venezuela', flag: '🇻🇪' },
+ { code: 'VN', name: 'Vietnam', flag: '🇻🇳' },
+ { code: 'WF', name: 'Wallis and Futuna', flag: '🇼🇫' },
+ { code: 'EH', name: 'Western Sahara', flag: '🇪🇭' },
+ { code: 'YE', name: 'Yemen', flag: '🇾🇪' },
+ { code: 'ZM', name: 'Zambia', flag: '🇿🇲' },
+ { code: 'ZW', name: 'Zimbabwe', flag: '🇿🇼' }
+] as const;
+
+export type CountryCode = (typeof countries)[number]['code'];
+
+export function getCountryByCode(code: string) {
+ return countries.find((c) => c.code === code);
+}
+
+export function getCountryName(code: string) {
+ return getCountryByCode(code)?.name || code;
+}
+
+export function getCountryFlag(code: string) {
+ return getCountryByCode(code)?.flag || '';
+}
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
new file mode 100644
index 0000000..5532ada
--- /dev/null
+++ b/src/lib/utils/index.ts
@@ -0,0 +1,59 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+/**
+ * Merge Tailwind CSS classes with proper precedence
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Format a date to a human-readable string
+ */
+export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ ...options
+ });
+}
+
+/**
+ * Format currency amount
+ */
+export function formatCurrency(amount: number, currency = 'EUR'): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency
+ }).format(amount);
+}
+
+/**
+ * Generate a random ID
+ */
+export function generateId(prefix = ''): string {
+ const id = Math.random().toString(36).substring(2, 9);
+ return prefix ? `${prefix}_${id}` : id;
+}
+
+/**
+ * Debounce a function
+ */
+export function debounce unknown>(
+ fn: T,
+ delay: number
+): (...args: Parameters) => void {
+ let timeoutId: ReturnType;
+ return (...args: Parameters) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => fn(...args), delay);
+ };
+}
+
+/**
+ * Check if running in browser
+ */
+export const isBrowser = typeof window !== 'undefined';
diff --git a/src/lib/utils/phoneCountries.ts b/src/lib/utils/phoneCountries.ts
new file mode 100644
index 0000000..a260690
--- /dev/null
+++ b/src/lib/utils/phoneCountries.ts
@@ -0,0 +1,255 @@
+// Complete phone country codes with dial codes
+// Prioritized: US, Monaco, France first, then common countries, then alphabetical
+export const phoneCountries = [
+ // Priority countries for Monaco USA
+ { code: 'US', name: 'United States', dialCode: '+1' },
+ { code: 'MC', name: 'Monaco', dialCode: '+377' },
+ { code: 'FR', name: 'France', dialCode: '+33' },
+ { code: 'GB', name: 'United Kingdom', dialCode: '+44' },
+ { code: 'IT', name: 'Italy', dialCode: '+39' },
+ { code: 'CH', name: 'Switzerland', dialCode: '+41' },
+ { code: 'DE', name: 'Germany', dialCode: '+49' },
+ { code: 'ES', name: 'Spain', dialCode: '+34' },
+ // All other countries alphabetically
+ { code: 'AF', name: 'Afghanistan', dialCode: '+93' },
+ { code: 'AL', name: 'Albania', dialCode: '+355' },
+ { code: 'DZ', name: 'Algeria', dialCode: '+213' },
+ { code: 'AS', name: 'American Samoa', dialCode: '+1684' },
+ { code: 'AD', name: 'Andorra', dialCode: '+376' },
+ { code: 'AO', name: 'Angola', dialCode: '+244' },
+ { code: 'AI', name: 'Anguilla', dialCode: '+1264' },
+ { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268' },
+ { code: 'AR', name: 'Argentina', dialCode: '+54' },
+ { code: 'AM', name: 'Armenia', dialCode: '+374' },
+ { code: 'AW', name: 'Aruba', dialCode: '+297' },
+ { code: 'AU', name: 'Australia', dialCode: '+61' },
+ { code: 'AT', name: 'Austria', dialCode: '+43' },
+ { code: 'AZ', name: 'Azerbaijan', dialCode: '+994' },
+ { code: 'BS', name: 'Bahamas', dialCode: '+1242' },
+ { code: 'BH', name: 'Bahrain', dialCode: '+973' },
+ { code: 'BD', name: 'Bangladesh', dialCode: '+880' },
+ { code: 'BB', name: 'Barbados', dialCode: '+1246' },
+ { code: 'BY', name: 'Belarus', dialCode: '+375' },
+ { code: 'BE', name: 'Belgium', dialCode: '+32' },
+ { code: 'BZ', name: 'Belize', dialCode: '+501' },
+ { code: 'BJ', name: 'Benin', dialCode: '+229' },
+ { code: 'BM', name: 'Bermuda', dialCode: '+1441' },
+ { code: 'BT', name: 'Bhutan', dialCode: '+975' },
+ { code: 'BO', name: 'Bolivia', dialCode: '+591' },
+ { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387' },
+ { code: 'BW', name: 'Botswana', dialCode: '+267' },
+ { code: 'BR', name: 'Brazil', dialCode: '+55' },
+ { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246' },
+ { code: 'VG', name: 'British Virgin Islands', dialCode: '+1284' },
+ { code: 'BN', name: 'Brunei', dialCode: '+673' },
+ { code: 'BG', name: 'Bulgaria', dialCode: '+359' },
+ { code: 'BF', name: 'Burkina Faso', dialCode: '+226' },
+ { code: 'BI', name: 'Burundi', dialCode: '+257' },
+ { code: 'CV', name: 'Cabo Verde', dialCode: '+238' },
+ { code: 'KH', name: 'Cambodia', dialCode: '+855' },
+ { code: 'CM', name: 'Cameroon', dialCode: '+237' },
+ { code: 'CA', name: 'Canada', dialCode: '+1' },
+ { code: 'KY', name: 'Cayman Islands', dialCode: '+1345' },
+ { code: 'CF', name: 'Central African Republic', dialCode: '+236' },
+ { code: 'TD', name: 'Chad', dialCode: '+235' },
+ { code: 'CL', name: 'Chile', dialCode: '+56' },
+ { code: 'CN', name: 'China', dialCode: '+86' },
+ { code: 'CX', name: 'Christmas Island', dialCode: '+61' },
+ { code: 'CC', name: 'Cocos (Keeling) Islands', dialCode: '+61' },
+ { code: 'CO', name: 'Colombia', dialCode: '+57' },
+ { code: 'KM', name: 'Comoros', dialCode: '+269' },
+ { code: 'CG', name: 'Congo', dialCode: '+242' },
+ { code: 'CD', name: 'Congo (DRC)', dialCode: '+243' },
+ { code: 'CK', name: 'Cook Islands', dialCode: '+682' },
+ { code: 'CR', name: 'Costa Rica', dialCode: '+506' },
+ { code: 'CI', name: "Côte d'Ivoire", dialCode: '+225' },
+ { code: 'HR', name: 'Croatia', dialCode: '+385' },
+ { code: 'CU', name: 'Cuba', dialCode: '+53' },
+ { code: 'CW', name: 'Curaçao', dialCode: '+599' },
+ { code: 'CY', name: 'Cyprus', dialCode: '+357' },
+ { code: 'CZ', name: 'Czech Republic', dialCode: '+420' },
+ { code: 'DK', name: 'Denmark', dialCode: '+45' },
+ { code: 'DJ', name: 'Djibouti', dialCode: '+253' },
+ { code: 'DM', name: 'Dominica', dialCode: '+1767' },
+ { code: 'DO', name: 'Dominican Republic', dialCode: '+1' },
+ { code: 'EC', name: 'Ecuador', dialCode: '+593' },
+ { code: 'EG', name: 'Egypt', dialCode: '+20' },
+ { code: 'SV', name: 'El Salvador', dialCode: '+503' },
+ { code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240' },
+ { code: 'ER', name: 'Eritrea', dialCode: '+291' },
+ { code: 'EE', name: 'Estonia', dialCode: '+372' },
+ { code: 'SZ', name: 'Eswatini', dialCode: '+268' },
+ { code: 'ET', name: 'Ethiopia', dialCode: '+251' },
+ { code: 'FK', name: 'Falkland Islands', dialCode: '+500' },
+ { code: 'FO', name: 'Faroe Islands', dialCode: '+298' },
+ { code: 'FJ', name: 'Fiji', dialCode: '+679' },
+ { code: 'FI', name: 'Finland', dialCode: '+358' },
+ { code: 'GF', name: 'French Guiana', dialCode: '+594' },
+ { code: 'PF', name: 'French Polynesia', dialCode: '+689' },
+ { code: 'GA', name: 'Gabon', dialCode: '+241' },
+ { code: 'GM', name: 'Gambia', dialCode: '+220' },
+ { code: 'GE', name: 'Georgia', dialCode: '+995' },
+ { code: 'GH', name: 'Ghana', dialCode: '+233' },
+ { code: 'GI', name: 'Gibraltar', dialCode: '+350' },
+ { code: 'GR', name: 'Greece', dialCode: '+30' },
+ { code: 'GL', name: 'Greenland', dialCode: '+299' },
+ { code: 'GD', name: 'Grenada', dialCode: '+1473' },
+ { code: 'GP', name: 'Guadeloupe', dialCode: '+590' },
+ { code: 'GU', name: 'Guam', dialCode: '+1671' },
+ { code: 'GT', name: 'Guatemala', dialCode: '+502' },
+ { code: 'GG', name: 'Guernsey', dialCode: '+44' },
+ { code: 'GN', name: 'Guinea', dialCode: '+224' },
+ { code: 'GW', name: 'Guinea-Bissau', dialCode: '+245' },
+ { code: 'GY', name: 'Guyana', dialCode: '+592' },
+ { code: 'HT', name: 'Haiti', dialCode: '+509' },
+ { code: 'HN', name: 'Honduras', dialCode: '+504' },
+ { code: 'HK', name: 'Hong Kong', dialCode: '+852' },
+ { code: 'HU', name: 'Hungary', dialCode: '+36' },
+ { code: 'IS', name: 'Iceland', dialCode: '+354' },
+ { code: 'IN', name: 'India', dialCode: '+91' },
+ { code: 'ID', name: 'Indonesia', dialCode: '+62' },
+ { code: 'IR', name: 'Iran', dialCode: '+98' },
+ { code: 'IQ', name: 'Iraq', dialCode: '+964' },
+ { code: 'IE', name: 'Ireland', dialCode: '+353' },
+ { code: 'IM', name: 'Isle of Man', dialCode: '+44' },
+ { code: 'IL', name: 'Israel', dialCode: '+972' },
+ { code: 'JM', name: 'Jamaica', dialCode: '+1' },
+ { code: 'JP', name: 'Japan', dialCode: '+81' },
+ { code: 'JE', name: 'Jersey', dialCode: '+44' },
+ { code: 'JO', name: 'Jordan', dialCode: '+962' },
+ { code: 'KZ', name: 'Kazakhstan', dialCode: '+7' },
+ { code: 'KE', name: 'Kenya', dialCode: '+254' },
+ { code: 'KI', name: 'Kiribati', dialCode: '+686' },
+ { code: 'KW', name: 'Kuwait', dialCode: '+965' },
+ { code: 'KG', name: 'Kyrgyzstan', dialCode: '+996' },
+ { code: 'LA', name: 'Laos', dialCode: '+856' },
+ { code: 'LV', name: 'Latvia', dialCode: '+371' },
+ { code: 'LB', name: 'Lebanon', dialCode: '+961' },
+ { code: 'LS', name: 'Lesotho', dialCode: '+266' },
+ { code: 'LR', name: 'Liberia', dialCode: '+231' },
+ { code: 'LY', name: 'Libya', dialCode: '+218' },
+ { code: 'LI', name: 'Liechtenstein', dialCode: '+423' },
+ { code: 'LT', name: 'Lithuania', dialCode: '+370' },
+ { code: 'LU', name: 'Luxembourg', dialCode: '+352' },
+ { code: 'MO', name: 'Macao', dialCode: '+853' },
+ { code: 'MG', name: 'Madagascar', dialCode: '+261' },
+ { code: 'MW', name: 'Malawi', dialCode: '+265' },
+ { code: 'MY', name: 'Malaysia', dialCode: '+60' },
+ { code: 'MV', name: 'Maldives', dialCode: '+960' },
+ { code: 'ML', name: 'Mali', dialCode: '+223' },
+ { code: 'MT', name: 'Malta', dialCode: '+356' },
+ { code: 'MH', name: 'Marshall Islands', dialCode: '+692' },
+ { code: 'MQ', name: 'Martinique', dialCode: '+596' },
+ { code: 'MR', name: 'Mauritania', dialCode: '+222' },
+ { code: 'MU', name: 'Mauritius', dialCode: '+230' },
+ { code: 'YT', name: 'Mayotte', dialCode: '+262' },
+ { code: 'MX', name: 'Mexico', dialCode: '+52' },
+ { code: 'FM', name: 'Micronesia', dialCode: '+691' },
+ { code: 'MD', name: 'Moldova', dialCode: '+373' },
+ { code: 'MN', name: 'Mongolia', dialCode: '+976' },
+ { code: 'ME', name: 'Montenegro', dialCode: '+382' },
+ { code: 'MS', name: 'Montserrat', dialCode: '+1664' },
+ { code: 'MA', name: 'Morocco', dialCode: '+212' },
+ { code: 'MZ', name: 'Mozambique', dialCode: '+258' },
+ { code: 'MM', name: 'Myanmar', dialCode: '+95' },
+ { code: 'NA', name: 'Namibia', dialCode: '+264' },
+ { code: 'NR', name: 'Nauru', dialCode: '+674' },
+ { code: 'NP', name: 'Nepal', dialCode: '+977' },
+ { code: 'NL', name: 'Netherlands', dialCode: '+31' },
+ { code: 'NC', name: 'New Caledonia', dialCode: '+687' },
+ { code: 'NZ', name: 'New Zealand', dialCode: '+64' },
+ { code: 'NI', name: 'Nicaragua', dialCode: '+505' },
+ { code: 'NE', name: 'Niger', dialCode: '+227' },
+ { code: 'NG', name: 'Nigeria', dialCode: '+234' },
+ { code: 'NU', name: 'Niue', dialCode: '+683' },
+ { code: 'NF', name: 'Norfolk Island', dialCode: '+672' },
+ { code: 'KP', name: 'North Korea', dialCode: '+850' },
+ { code: 'MK', name: 'North Macedonia', dialCode: '+389' },
+ { code: 'MP', name: 'Northern Mariana Islands', dialCode: '+1670' },
+ { code: 'NO', name: 'Norway', dialCode: '+47' },
+ { code: 'OM', name: 'Oman', dialCode: '+968' },
+ { code: 'PK', name: 'Pakistan', dialCode: '+92' },
+ { code: 'PW', name: 'Palau', dialCode: '+680' },
+ { code: 'PS', name: 'Palestine', dialCode: '+970' },
+ { code: 'PA', name: 'Panama', dialCode: '+507' },
+ { code: 'PG', name: 'Papua New Guinea', dialCode: '+675' },
+ { code: 'PY', name: 'Paraguay', dialCode: '+595' },
+ { code: 'PE', name: 'Peru', dialCode: '+51' },
+ { code: 'PH', name: 'Philippines', dialCode: '+63' },
+ { code: 'PL', name: 'Poland', dialCode: '+48' },
+ { code: 'PT', name: 'Portugal', dialCode: '+351' },
+ { code: 'PR', name: 'Puerto Rico', dialCode: '+1' },
+ { code: 'QA', name: 'Qatar', dialCode: '+974' },
+ { code: 'RE', name: 'Réunion', dialCode: '+262' },
+ { code: 'RO', name: 'Romania', dialCode: '+40' },
+ { code: 'RU', name: 'Russia', dialCode: '+7' },
+ { code: 'RW', name: 'Rwanda', dialCode: '+250' },
+ { code: 'BL', name: 'Saint Barthélemy', dialCode: '+590' },
+ { code: 'SH', name: 'Saint Helena', dialCode: '+290' },
+ { code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869' },
+ { code: 'LC', name: 'Saint Lucia', dialCode: '+1758' },
+ { code: 'MF', name: 'Saint Martin', dialCode: '+590' },
+ { code: 'PM', name: 'Saint Pierre and Miquelon', dialCode: '+508' },
+ { code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784' },
+ { code: 'WS', name: 'Samoa', dialCode: '+685' },
+ { code: 'SM', name: 'San Marino', dialCode: '+378' },
+ { code: 'ST', name: 'São Tomé and Príncipe', dialCode: '+239' },
+ { code: 'SA', name: 'Saudi Arabia', dialCode: '+966' },
+ { code: 'SN', name: 'Senegal', dialCode: '+221' },
+ { code: 'RS', name: 'Serbia', dialCode: '+381' },
+ { code: 'SC', name: 'Seychelles', dialCode: '+248' },
+ { code: 'SL', name: 'Sierra Leone', dialCode: '+232' },
+ { code: 'SG', name: 'Singapore', dialCode: '+65' },
+ { code: 'SX', name: 'Sint Maarten', dialCode: '+1721' },
+ { code: 'SK', name: 'Slovakia', dialCode: '+421' },
+ { code: 'SI', name: 'Slovenia', dialCode: '+386' },
+ { code: 'SB', name: 'Solomon Islands', dialCode: '+677' },
+ { code: 'SO', name: 'Somalia', dialCode: '+252' },
+ { code: 'ZA', name: 'South Africa', dialCode: '+27' },
+ { code: 'KR', name: 'South Korea', dialCode: '+82' },
+ { code: 'SS', name: 'South Sudan', dialCode: '+211' },
+ { code: 'LK', name: 'Sri Lanka', dialCode: '+94' },
+ { code: 'SD', name: 'Sudan', dialCode: '+249' },
+ { code: 'SR', name: 'Suriname', dialCode: '+597' },
+ { code: 'SJ', name: 'Svalbard and Jan Mayen', dialCode: '+47' },
+ { code: 'SE', name: 'Sweden', dialCode: '+46' },
+ { code: 'SY', name: 'Syria', dialCode: '+963' },
+ { code: 'TW', name: 'Taiwan', dialCode: '+886' },
+ { code: 'TJ', name: 'Tajikistan', dialCode: '+992' },
+ { code: 'TZ', name: 'Tanzania', dialCode: '+255' },
+ { code: 'TH', name: 'Thailand', dialCode: '+66' },
+ { code: 'TL', name: 'Timor-Leste', dialCode: '+670' },
+ { code: 'TG', name: 'Togo', dialCode: '+228' },
+ { code: 'TK', name: 'Tokelau', dialCode: '+690' },
+ { code: 'TO', name: 'Tonga', dialCode: '+676' },
+ { code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1' },
+ { code: 'TN', name: 'Tunisia', dialCode: '+216' },
+ { code: 'TR', name: 'Turkey', dialCode: '+90' },
+ { code: 'TM', name: 'Turkmenistan', dialCode: '+993' },
+ { code: 'TC', name: 'Turks and Caicos Islands', dialCode: '+1649' },
+ { code: 'TV', name: 'Tuvalu', dialCode: '+688' },
+ { code: 'UG', name: 'Uganda', dialCode: '+256' },
+ { code: 'UA', name: 'Ukraine', dialCode: '+380' },
+ { code: 'AE', name: 'United Arab Emirates', dialCode: '+971' },
+ { code: 'UY', name: 'Uruguay', dialCode: '+598' },
+ { code: 'VI', name: 'United States Virgin Islands', dialCode: '+1340' },
+ { code: 'UZ', name: 'Uzbekistan', dialCode: '+998' },
+ { code: 'VU', name: 'Vanuatu', dialCode: '+678' },
+ { code: 'VA', name: 'Vatican City', dialCode: '+39' },
+ { code: 'VE', name: 'Venezuela', dialCode: '+58' },
+ { code: 'VN', name: 'Vietnam', dialCode: '+84' },
+ { code: 'WF', name: 'Wallis and Futuna', dialCode: '+681' },
+ { code: 'YE', name: 'Yemen', dialCode: '+967' },
+ { code: 'ZM', name: 'Zambia', dialCode: '+260' },
+ { code: 'ZW', name: 'Zimbabwe', dialCode: '+263' }
+] as const;
+
+export type PhoneCountryCode = (typeof phoneCountries)[number]['code'];
+
+export function getPhoneCountry(code: string) {
+ return phoneCountries.find((c) => c.code === code);
+}
+
+export function getDialCode(code: string) {
+ return getPhoneCountry(code)?.dialCode || '';
+}
diff --git a/src/routes/(app)/+error.svelte b/src/routes/(app)/+error.svelte
new file mode 100644
index 0000000..d5fc1a5
--- /dev/null
+++ b/src/routes/(app)/+error.svelte
@@ -0,0 +1,179 @@
+
+
+
+ {status} - {errorInfo.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error {status}
+
+
+
+
{errorInfo.title}
+
+
+
{errorInfo.message}
+
+
+
+
+
+
+
+ Dashboard
+
+
+
history.back()}>
+
+
+
+ Go Back
+
+
+ {#if status === 401}
+
+
+
+
+ Sign In
+
+ {/if}
+
+
+
+
+
+ Need assistance?
+
+ Contact support
+
+
+
+
+ {#if $page.error?.message && import.meta.env.DEV}
+
+
+ Technical details
+
+ {$page.error.message}
+
+ {/if}
+
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
new file mode 100644
index 0000000..2e97741
--- /dev/null
+++ b/src/routes/(app)/+layout.server.ts
@@ -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
+ };
+};
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..c453b4f
--- /dev/null
+++ b/src/routes/(app)/+layout.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !data.emailVerified}
+
+ {/if}
+
+
+
+
+ {@render children()}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts
new file mode 100644
index 0000000..0f15a57
--- /dev/null
+++ b/src/routes/(app)/admin/+layout.server.ts
@@ -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 {};
+};
diff --git a/src/routes/(app)/admin/dashboard/+page.server.ts b/src/routes/(app)/admin/dashboard/+page.server.ts
new file mode 100644
index 0000000..1e16518
--- /dev/null
+++ b/src/routes/(app)/admin/dashboard/+page.server.ts
@@ -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) || []
+ };
+};
diff --git a/src/routes/(app)/admin/dashboard/+page.svelte b/src/routes/(app)/admin/dashboard/+page.svelte
new file mode 100644
index 0000000..3e1634e
--- /dev/null
+++ b/src/routes/(app)/admin/dashboard/+page.svelte
@@ -0,0 +1,295 @@
+
+
+
+ Admin Dashboard | Monaco USA
+
+
+
+
+
+
Admin Dashboard
+
Overview of Monaco USA portal activity and metrics
+
+
+
+
+
+
+
+
+
Total Members
+
{memberStats.total}
+
+
+
+
+
+
+ {memberStats.byRole.admin} Admin
+ {memberStats.byRole.board} Board
+ {memberStats.byRole.member} Members
+
+
+
+
+
+
+
+
Revenue This Month
+
{formatCurrency(revenueStats.thisMonth)}
+
+
+
+
+
+
+ {#if revenueUp}
+
+ +{revenueTrend}%
+ {:else}
+
+ {revenueTrend}%
+ {/if}
+ vs last month
+
+
+
+
+
+
+
+
Dues Status
+
{memberStats.byDuesStatus.current}
+
+
+
+
+ Current
+ {memberStats.byDuesStatus.due_soon} Due Soon
+ {memberStats.byDuesStatus.overdue} Overdue
+
+
+
+
+
+
+
+
Upcoming Events
+
{upcomingEvents.length}
+
+
+
+
+
+
Next 30 days
+
+
+
+
+
+
+
+
+
+ {#if recentPayments.length > 0}
+
+ {#each recentPayments as payment}
+
+
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+
{formatDate(payment.payment_date)}
+
+
{formatCurrency(payment.amount)}
+
+ {/each}
+
+ {:else}
+
No recent payments
+ {/if}
+
+
+
+
+
+
+ {#if upcomingEvents.length > 0}
+
+ {#each upcomingEvents as event}
+
+
+
{event.title}
+
+ {formatDateTime(event.start_datetime)}
+ {#if event.location}
+ | {event.location}
+ {/if}
+
+
+
+
{event.total_attendees}
+ {#if event.max_attendees}
+
/{event.max_attendees}
+ {/if}
+
attendees
+
+
+ {/each}
+
+ {:else}
+
No upcoming events
+ {/if}
+
+
+
+
+
+
+ {#if overdueMembers.length > 0}
+
+ {#each overdueMembers as member}
+
+
+
+ {member.first_name} {member.last_name}
+
+
{member.email}
+
+
+
+ {member.days_overdue} days
+
+
overdue
+
+
+ {/each}
+
+ {:else}
+
+
+
All members are current!
+
+ {/if}
+
+
+
+
+
+
+ {#if auditLogs.length > 0}
+
+ {#each auditLogs.slice(0, 8) as log}
+
+
+ {#if log.action.startsWith('member')}
+
+ {:else if log.action.startsWith('event')}
+
+ {:else if log.action.startsWith('payment')}
+
+ {:else if log.action.startsWith('document')}
+
+ {:else}
+
+ {/if}
+
+
+
+ {formatAuditAction(log.action)}
+ {#if log.details?.target_email}
+ ({log.details.target_email})
+ {/if}
+
+
+ {log.user_email || 'System'} · {formatDateTime(log.created_at)}
+
+
+
+ {/each}
+
+ {:else}
+
No recent activity
+ {/if}
+
+
+
diff --git a/src/routes/(app)/admin/email-templates/+page.server.ts b/src/routes/(app)/admin/email-templates/+page.server.ts
new file mode 100644
index 0000000..5535763
--- /dev/null
+++ b/src/routes/(app)/admin/email-templates/+page.server.ts
@@ -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' };
+ }
+};
diff --git a/src/routes/(app)/admin/email-templates/+page.svelte b/src/routes/(app)/admin/email-templates/+page.svelte
new file mode 100644
index 0000000..e0af0d4
--- /dev/null
+++ b/src/routes/(app)/admin/email-templates/+page.svelte
@@ -0,0 +1,566 @@
+
+
+
+ Email Templates | Monaco USA Admin
+
+
+
+
+
+
+
+
Email Templates
+
Edit the text content of email notifications sent by the system
+
+
+
+ {#if form?.success}
+
+
+ {form.message || 'Template updated successfully'}
+
+ {/if}
+
+ {#if form?.error}
+
+
+ {form.error}
+
+ {/if}
+
+
+
+
+
+
+ Category:
+
+ {#each categories as cat}
+ {cat === 'all' ? 'All Categories' : formatCategory(cat)}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each filteredTemplates as template}
+
+
+
+
+
+ {formatCategory(template.category)}
+
+ {#if !template.is_active}
+
+ Inactive
+
+ {/if}
+
+
{template.template_name}
+
{template.subject}
+
+
+
+
+ {template.template_key}
+ openEditor(template)}
+ >
+ Edit
+
+
+
+ {:else}
+
+ {/each}
+
+
+
+
+{#if editingTemplate}
+ { if (e.target === e.currentTarget) closeEditor(); }}
+ onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
+ role="dialog"
+ aria-modal="true"
+ tabindex="-1"
+ >
+
+
+
+
+
Edit: {editingTemplate.template_name}
+
{editingTemplate.template_key}
+
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/email-testing/+page.server.ts b/src/routes/(app)/admin/email-testing/+page.server.ts
new file mode 100644
index 0000000..46d760d
--- /dev/null
+++ b/src/routes/(app)/admin/email-testing/+page.server.ts
@@ -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 = {};
+ 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: `${messageContent.replace(/\n/g, ' ')}
`,
+ 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 = {
+ 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 {
+ 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 = {
+ 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;
+ }
+}
diff --git a/src/routes/(app)/admin/email-testing/+page.svelte b/src/routes/(app)/admin/email-testing/+page.svelte
new file mode 100644
index 0000000..3001952
--- /dev/null
+++ b/src/routes/(app)/admin/email-testing/+page.svelte
@@ -0,0 +1,561 @@
+
+
+
+ Email Testing | Monaco USA Admin
+
+
+
+
+
+
+
+ {#if !smtpConfigured}
+
+
+
+
+
SMTP Not Configured
+
+ Email sending is not configured. Please configure SMTP settings in the
+ Admin Settings
+ before testing emails.
+
+
+
+
+ {/if}
+
+
+ {#if form?.success}
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
+
+
+ (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'}"
+ >
+
+
+ Email Templates
+
+
+ (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'}"
+ >
+
+
+ Custom Email
+
+
+ (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'}"
+ >
+
+
+ Recent Logs ({recentLogs.length})
+
+
+
+
+
+ {#if activeTab === 'templates'}
+
+
+
+ {#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
+
+
+
{getCategoryName(category)}
+
+
+ {#each categoryTemplates as template}
+
(selectedTemplate = template.template_key)}
+ onkeydown={(e) => e.key === 'Enter' && (selectedTemplate = template.template_key)}
+ role="button"
+ tabindex="0"
+ >
+
+
{template.name}
+
{template.subject}
+
+
+ {#if template.is_active}
+
+ Active
+
+ {:else}
+
+ Inactive
+
+ {/if}
+
+
+ {/each}
+
+
+ {/each}
+
+ {#if !templates || templates.length === 0}
+
+
+
No Email Templates
+
+ No email templates have been configured yet.
+
+
+ {/if}
+
+
+
+
+
+
Send Test Email
+
+
{
+ isLoading = true;
+ return async ({ update }) => {
+ isLoading = false;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Recipient Email
+
+
+
+
+ Selected Template
+
+ Select a template...
+ {#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
+
+ {#each categoryTemplates as template}
+ {template.name}
+ {/each}
+
+ {/each}
+
+
+
+
+
+ {#if isLoading}
+
+ Sending...
+ {:else}
+
+ Send Test
+ {/if}
+
+
+
+
+
+ {#if selectedTemplate}
+
{
+ 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"
+ >
+
+
+ {#if isLoadingPreview}
+
+ Loading...
+ {:else}
+
+ Preview Template
+ {/if}
+
+
+ {/if}
+
+
+
+
Bulk Testing
+
{
+ 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();
+ };
+ }}
+ >
+
+
+
+ Send All Templates
+
+
+
+ Sends all active templates to test the complete notification system.
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'custom'}
+
+
+
Send Custom Test Email
+
+ Send a custom email using the Monaco USA branding template.
+
+
+
{
+ isLoading = true;
+ return async ({ update }) => {
+ isLoading = false;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Recipient Email
+
+
+
+
+ Subject
+
+
+
+
+
Message
+
+
+ The message will be wrapped in the Monaco USA email template.
+
+
+
+
+ {#if isLoading}
+
+ Sending...
+ {:else}
+
+ Send Custom Email
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'logs'}
+
+
+
Recent Email Logs
+ invalidateAll()}
+ class="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900"
+ >
+
+ Refresh
+
+
+
+ {#if recentLogs.length === 0}
+
+
+
No Email Logs
+
No emails have been sent yet.
+
+ {:else}
+
+
+
+
+ Status
+ Recipient
+ Subject
+ Type
+ Sent
+ By
+
+
+
+ {#each recentLogs as log}
+ {@const statusBadge = getStatusBadge(log.status)}
+
+
+
+
+ {log.status}
+
+
+
+
+
{log.recipient_email}
+ {#if log.recipient_name}
+
{log.recipient_name}
+ {/if}
+
+
+
+
+ {log.subject}
+
+
+
+
+ {log.email_type || 'manual'}
+
+
+
+ {log.sent_at ? formatDate(log.sent_at) : formatDate(log.created_at)}
+
+
+ {#if log.sender}
+ {log.sender.first_name} {log.sender.last_name}
+ {:else}
+ System
+ {/if}
+
+
+ {#if log.status === 'failed' && log.error_message}
+
+
+
+ Error: {log.error_message}
+
+
+
+ {/if}
+ {/each}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
+{#if showPreview}
+
+
+
+
+
Email Preview
+
{previewSubject}
+
+
(showPreview = false)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+
+
+
+
+
+ {@html previewHtml}
+
+
+
+ (showPreview = false)}>
+ Close Preview
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/members/+page.server.ts b/src/routes/(app)/admin/members/+page.server.ts
new file mode 100644
index 0000000..a56b1ad
--- /dev/null
+++ b/src/routes/(app)/admin/members/+page.server.ts
@@ -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
+ ? `
+
Membership Status
+
Status: Active Member
+
Dues Paid Through: ${formattedDuesPaidDate}
+
`
+ : '';
+
+ const welcomeEmailResult = await sendEmail({
+ to: email,
+ subject: `Welcome to Monaco USA, ${memberFirstName}!`,
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Welcome to Monaco USA!
+ Dear ${memberFirstName},
+ 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.
+ ${duesSection}
+
+
To get started:
+
+ You will receive a separate email shortly to set up your password
+ Log in to your member portal at ${baseUrl}
+ Complete your profile with your details
+ Explore upcoming events and connect with fellow members
+
+
+ If you have any questions, please don't hesitate to reach out to our board members.
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+`,
+ 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.`
+ };
+ }
+};
diff --git a/src/routes/(app)/admin/members/+page.svelte b/src/routes/(app)/admin/members/+page.svelte
new file mode 100644
index 0000000..74ff84d
--- /dev/null
+++ b/src/routes/(app)/admin/members/+page.svelte
@@ -0,0 +1,596 @@
+
+
+
+ User Management | Monaco USA Admin
+
+
+
+
+
+
User Management
+
Manage member accounts, roles, and statuses
+
+
+
+ Invite Member
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Users
+
+
+
+
+
+
+
+
+
+
{stats.admins}
+
Admins
+
+
+
+
+
+
+
+
+
+
{stats.board}
+
Board Members
+
+
+
+
+
+
+
+
+
+
{stats.members}
+
Members
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
updateFilters({ role: roleFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Roles
+ Admin
+ Board
+ Member
+
+
+
updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Statuses
+ {#each statuses as status}
+ {status.display_name}
+ {/each}
+
+
+
+
+
+
+ {#if members.length === 0}
+
+
+
No users found
+
Try adjusting your search or filters.
+
+ {:else}
+
+
+
+
+ User
+ Contact
+ Role
+ Status
+ Joined
+ Actions
+
+
+
+ {#each members as member}
+ {@const roleInfo = getRoleInfo(member.role)}
+ {@const statusInfo = getStatusInfo(member.status_name)}
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+
+ {member.first_name} {member.last_name}
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
{member.member_id}
+
+
+
+
+
+
+
+ {member.email}
+
+ {#if member.phone}
+
+ {/if}
+
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ 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"
+ >
+ Member
+ Board
+ Admin
+
+
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ 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}
+ {status.display_name}
+ {/each}
+
+
+
+
+ {formatDate(member.member_since)}
+
+
+ confirmDelete(member)}
+ class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
+ title="Delete Member"
+ >
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showDeleteConfirm && memberToDelete}
+
+
+
+
+
+ Are you sure you want to delete {memberToDelete.first_name} {memberToDelete.last_name}
+ ({memberToDelete.member_id})? This action cannot be undone.
+
+
+
+ This will permanently delete their account, payment history, and all associated data.
+
+
+
+ {
+ 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
+
+
{
+ return async ({ update, result }) => {
+ if (result.type === 'success') {
+ showDeleteConfirm = false;
+ memberToDelete = null;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="flex-1"
+ >
+
+
+ Delete Member
+
+
+
+
+
+{/if}
+
+
+{#if showInviteModal}
+
+
+
+
+
Invite New Member
+
+
+
+ Send an invitation email to a new member. They will receive instructions to set up their account.
+
+
+
{
+ inviteLoading = true;
+ return async ({ update, result }) => {
+ inviteLoading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Email Address *
+
+
+
+
+
+
+
+ Role
+
+ Member
+ Board
+ Admin
+
+
+
+ Dues Paid Until
+ 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"
+ />
+
+
+
+
+ * Required field. Other fields are optional - the member can update their profile after joining.
+
+
+
+ {
+ showInviteModal = false;
+ inviteEmail = '';
+ inviteFirstName = '';
+ inviteLastName = '';
+ inviteRole = 'member';
+ inviteDuesPaidDate = getDefaultDuesPaidDate();
+ }}
+ disabled={inviteLoading}
+ class="flex-1"
+ >
+ Cancel
+
+
+ {#if inviteLoading}
+
+ Sending...
+ {:else}
+
+ Send Invitation
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/settings/+page.server.ts b/src/routes/(app)/admin/settings/+page.server.ts
new file mode 100644
index 0000000..47105ab
--- /dev/null
+++ b/src/routes/(app)/admin/settings/+page.server.ts
@@ -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> = {};
+ 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();
+
+ 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> = {
+ // 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 = {};
+ 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 = {};
+ 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 = {};
+ 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 = {};
+ 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 = {};
+ 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!` };
+ }
+};
diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte
new file mode 100644
index 0000000..de8a85e
--- /dev/null
+++ b/src/routes/(app)/admin/settings/+page.svelte
@@ -0,0 +1,1703 @@
+
+
+
+ Settings | Monaco USA Admin
+
+
+
+
+
Settings
+
Configure membership types, dues, events, and more
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+ {#each tabs as tab}
+ (activeTab = tab.id)}
+ class="flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab ===
+ tab.id
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+
+ {tab.label}
+
+ {/each}
+
+
+
+
+ {#if activeTab === 'membership'}
+
+
+
+
+
+
Membership Statuses
+
Define the different states a membership can be in
+
+
(showAddStatusModal = true)}
+ 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"
+ >
+
+ Add Status
+
+
+
+
+
+
+
+ Name
+ Display
+ Color
+ Default
+ Actions
+
+
+
+ {#each membershipStatuses as status}
+
+ {status.name}
+ {status.display_name}
+
+
+
+
+ {status.is_default ? 'Yes' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
Membership Types
+
Define different membership tiers with pricing
+
+
(showAddTypeModal = true)}
+ 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"
+ >
+
+ Add Type
+
+
+
+
+
+
+
+ Name
+ Display
+ Annual Dues
+ Default
+ Actions
+
+
+
+ {#each membershipTypes as type}
+
+ {type.name}
+ {type.display_name}
+ €{type.annual_dues.toFixed(2)}
+
+ {type.is_default ? 'Yes' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'dues'}
+
+
Payment Settings
+
Configure bank details and payment instructions shown to members
+
+
+
+
+
+
+
+ Save Payment Settings
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'events'}
+
+
+
+
Event Types
+
Define the different types of events
+
+
(showAddEventTypeModal = true)}
+ 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"
+ >
+
+ Add Event Type
+
+
+
+
+
+
+
+ Name
+ Display
+ Color
+ Actions
+
+
+
+ {#each eventTypes as eventType}
+
+ {eventType.name}
+ {eventType.display_name}
+
+
+ {eventType.display_name}
+
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'documents'}
+
+
+
+
Document Categories
+
Define categories for organizing documents
+
+
(showAddCategoryModal = true)}
+ 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"
+ >
+
+ Add Category
+
+
+
+
+
+
+
+ Name
+ Display
+ Description
+ Actions
+
+
+
+ {#each documentCategories as category}
+
+ {category.name}
+ {category.display_name}
+ {category.description || '-'}
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'email'}
+
+
+
SMTP Email Configuration
+
Configure outgoing email settings for notifications and reminders
+
+
+ {#if smtpTestResult}
+
+ {#if smtpTestResult.success}
+
+ {:else}
+
+ {/if}
+ {smtpTestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Enable Email Sending
+
When enabled, the system will send email notifications
+
+
+
+
+
+
Server Settings
+
+
+
+ Use TLS/SSL (recommended for ports 465, 587)
+
+
+
+
+
+
Authentication
+
+
+ Username
+
+
+
+
Password
+
+
+ (showSmtpPassword = !showSmtpPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showSmtpPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ Save Email Settings
+
+
+
+
+
{
+ testingSmtp = true;
+ smtpTestResult = null;
+ return async ({ result }) => {
+ testingSmtp = false;
+ if (result.type === 'success' && result.data?.success) {
+ smtpTestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ smtpTestResult = { success: false, message: result.data.error };
+ } else {
+ smtpTestResult = { success: false, message: 'Test failed. Please check your settings.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingSmtp}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+
+
+
+
+
+
Test Email Templates
+
Send test versions of each email template to yourself
+
+
+
+ Edit Templates
+
+
+
+ {#if templateTestResult}
+
+ {#if templateTestResult.success}
+
+ {:else}
+
+ {/if}
+ {templateTestResult.message}
+
+ {/if}
+
+
+ {#each Object.entries(templatesByCategory) as [category, templates]}
+
+
+ {category === 'events' ? 'Events' : category === 'payment' ? 'Payments & Dues' : category === 'auth' ? 'Authentication' : category === 'member' ? 'Membership' : 'Other'}
+
+
+ {#each templates as template}
+
{
+ testingTemplate = template.template_key;
+ templateTestResult = null;
+ return async ({ result }) => {
+ testingTemplate = null;
+ if (result.type === 'success' && result.data?.success) {
+ templateTestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ templateTestResult = { success: false, message: result.data.error };
+ } else {
+ templateTestResult = { success: false, message: 'Failed to send test email' };
+ }
+ };
+ }}
+ >
+
+
+
+
{template.template_name}
+
{template.template_key}
+
+ {#if testingTemplate === template.template_key}
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/each}
+
+ {#if !emailTemplates || emailTemplates.length === 0}
+
No email templates found in the database.
+ {/if}
+
+
+ {/if}
+
+
+ {#if activeTab === 'poste'}
+
+
+
+
+
Poste Mail Server Configuration
+
Configure connection to your Poste.io mail server to manage @monacousa.org email accounts
+
+
+ {#if posteTestResult}
+
+ {#if posteTestResult.success}
+
+ {:else}
+
+ {/if}
+ {posteTestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
API Host
+
+
The hostname of your Poste mail server
+
+
+ Admin Email
+
+
+
+
Admin Password
+
+
+ (showPostePassword = !showPostePassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPostePassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ Email Domain
+
+
+
+
+
+
+ Save Configuration
+
+
+
+
+
{
+ testingPoste = true;
+ posteTestResult = null;
+ return async ({ result }) => {
+ testingPoste = false;
+ if (result.type === 'success' && result.data?.success) {
+ posteTestResult = { success: true, message: result.data.success };
+ await loadMailboxes();
+ } else if (result.type === 'failure' && result.data?.error) {
+ posteTestResult = { success: false, message: result.data.error };
+ } else {
+ posteTestResult = { success: false, message: 'Connection test failed.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingPoste}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+
+
+
+
+
+
Email Accounts
+
Manage @monacousa.org email accounts for board members
+
+
+
+
+ Refresh
+
+
(showCreateMailboxModal = true)}
+ 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"
+ >
+
+ Create Mailbox
+
+
+
+
+ {#if generatedPassword}
+
+
+
+
Mailbox created! Generated password:
+
{generatedPassword}
+
+
{ copyPassword(generatedPassword!); generatedPassword = null; }}
+ class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
+ >
+
+ Copy & Dismiss
+
+
+
Save this password now - it cannot be retrieved later.
+
+ {/if}
+
+ {#if loadingMailboxes}
+
+
+
+ {:else if mailboxes.length === 0}
+
+
+
No mailboxes loaded. Click "Test Connection" to load mailboxes.
+
+ {:else}
+
+
+
+
+ Email
+ Display Name
+ Status
+ Actions
+
+
+
+ {#each mailboxes as mailbox}
+
+
+ {mailbox.address}
+ {#if mailbox.super_admin}
+ Admin
+ {/if}
+
+ {mailbox.name || '-'}
+
+ {#if mailbox.disabled}
+
+
+ Disabled
+
+ {:else}
+
+
+ Active
+
+ {/if}
+
+
+ {
+ if (!confirm(`Delete mailbox ${mailbox.address}? This cannot be undone.`)) {
+ return async () => {};
+ }
+ return async ({ result }) => {
+ if (result.type === 'success') {
+ await loadMailboxes();
+ }
+ };
+ }}
+ >
+
+
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if activeTab === 'storage'}
+
+
+
S3/MinIO Storage Configuration
+
Configure external S3-compatible storage for documents and files
+
+
+ {#if s3TestResult}
+
+ {#if s3TestResult.success}
+
+ {:else}
+
+ {/if}
+ {s3TestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Enable S3 Storage
+
Use external S3/MinIO instead of Supabase Storage
+
+
+
+
+
+
Connection Settings
+
+
+
Endpoint URL
+
+
For MinIO, use your MinIO server URL. For AWS S3, leave empty or use regional endpoint.
+
+
+ Bucket Name
+
+
+
+
Region
+
+
Use us-east-1 for MinIO
+
+
+
+
+
+
+
Credentials
+
+
+ Access Key ID
+
+
+
+
Secret Access Key
+
+
+ (showS3SecretKey = !showS3SecretKey)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showS3SecretKey}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Options
+
+
+
+ Use SSL/HTTPS
+
+
+
+
+
Force Path Style
+
Required for MinIO and some S3-compatible services
+
+
+
+
+
+
+
+ Save Storage Settings
+
+
+
+
+
{
+ testingS3 = true;
+ s3TestResult = null;
+ return async ({ result }) => {
+ testingS3 = false;
+ if (result.type === 'success' && result.data?.success) {
+ s3TestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ s3TestResult = { success: false, message: result.data.error };
+ } else {
+ s3TestResult = { success: false, message: 'S3 test failed. Please check your settings.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingS3}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'system'}
+
+
+
+
Maintenance Mode
+
+
+
+
+
+
+
Enable Maintenance Mode
+
When enabled, non-admin users will see the maintenance message
+
+
+
+
+ Maintenance Message
+ {getSetting('system', 'maintenance_message', 'The portal is currently undergoing maintenance. Please check back soon.')}
+
+
+
+ Save Maintenance Settings
+
+
+
+
+
+
+
Session & Security
+
+
+
+
+
Session Timeout (hours)
+
+
Default is 168 hours (7 days). Max is 720 hours (30 days).
+
+
+
+ Save Security Settings
+
+
+
+
+
+
+
File Uploads
+
+
+
+
+ Maximum Upload Size (MB)
+
+
+
+
+
Allowed File Types
+
+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, JPEG, PNG, WEBP
+
+
File type restrictions are enforced on upload
+
+
+
+ Save Upload Settings
+
+
+
+
+
+
+
Public Access
+
+
+
+
+
+
+
+
Enable Public Events Page
+
Allow non-members to view events marked as public
+
+
+
+
+
+
+
Enable Public RSVP
+
Allow non-members to RSVP to public events
+
+
+
+
+
+ Save Public Access Settings
+
+
+
+
+ {/if}
+
+
+
+{#if showAddStatusModal}
+
+{/if}
+
+
+{#if showAddTypeModal}
+
+{/if}
+
+
+{#if showAddEventTypeModal}
+
+{/if}
+
+
+{#if showAddCategoryModal}
+
+{/if}
+
+
+{#if showCreateMailboxModal}
+
+
+
+
+
+
+
+
Create Email Account
+
+
(showCreateMailboxModal = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
{
+ creatingMailbox = true;
+ return async ({ result }) => {
+ creatingMailbox = false;
+ if (result.type === 'success') {
+ showCreateMailboxModal = false;
+ if (result.data?.generatedPassword) {
+ generatedPassword = result.data.generatedPassword;
+ }
+ await loadMailboxes();
+ }
+ };
+ }}
+ class="space-y-4"
+ >
+
+
Email Address
+
+
+
+ @{getSetting('poste', 'poste_domain', 'monacousa.org')}
+
+
+
Letters, numbers, dots, dashes, and underscores only
+
+
+
+ Display Name
+
+
+
+
+
Password (optional)
+
+
+
+
+
If left empty, a secure password will be generated
+
+
+
+ (showCreateMailboxModal = 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
+
+
+ {#if creatingMailbox}
+
+
+ Creating...
+
+ {:else}
+ Create Mailbox
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/+layout.server.ts b/src/routes/(app)/board/+layout.server.ts
new file mode 100644
index 0000000..41f7dc7
--- /dev/null
+++ b/src/routes/(app)/board/+layout.server.ts
@@ -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 {};
+};
diff --git a/src/routes/(app)/board/documents/+page.server.ts b/src/routes/(app)/board/documents/+page.server.ts
new file mode 100644
index 0000000..f1bb830
--- /dev/null
+++ b/src/routes/(app)/board/documents/+page.server.ts
@@ -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 };
+ }
+};
diff --git a/src/routes/(app)/board/documents/+page.svelte b/src/routes/(app)/board/documents/+page.svelte
new file mode 100644
index 0000000..2eb1750
--- /dev/null
+++ b/src/routes/(app)/board/documents/+page.svelte
@@ -0,0 +1,719 @@
+
+
+
+ Document Management | Monaco USA
+
+
+
+
+
+
+
Document Management
+
Upload and manage association documents
+
+
+
+ {#if canEdit}
+ (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"
+ >
+
+ New Folder
+
+ {/if}
+
+ (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 Document
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+ {#if breadcrumbs && breadcrumbs.length > 0}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ All Categories
+ {#each categories as category}
+ {category.display_name}
+ {/each}
+
+
+
+
+
+ {#if filteredFolders.length > 0}
+
+
Folders
+
+ {#each filteredFolders as folder}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if filteredFolders.length === 0 && filteredDocuments.length === 0}
+
+
+
+ {currentFolderId ? 'This folder is empty' : 'No documents found'}
+
+
+ {searchQuery || selectedCategory
+ ? 'Try adjusting your search or filters.'
+ : currentFolderId
+ ? 'Upload documents or create subfolders to get started.'
+ : 'Upload your first document to get started.'}
+
+
+ {:else if filteredDocuments.length === 0}
+
+
+
No documents in this location
+
+ Documents you upload here will appear in this list.
+
+
+ {:else}
+
+
+ Documents ({filteredDocuments.length})
+
+
+
+
+
+
+ Document
+
+
+ Category
+
+
+ Visibility
+
+
+ Uploaded
+
+
+ Actions
+
+
+
+
+ {#each filteredDocuments as doc}
+ {@const visInfo = getVisibilityLabel(doc.visibility)}
+
+
+ openPreview(doc)}
+ class="flex items-center gap-3 text-left hover:text-monaco-600 transition-colors"
+ >
+
+
+
{doc.title}
+
+ {doc.file_name} ({formatFileSize(doc.file_size)})
+
+
+
+
+
+ {doc.category?.display_name || '-'}
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ 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}"
+ >
+ Public
+ Members
+ Board
+ Admin
+
+
+
+
+
+
{formatDate(doc.created_at)}
+
+ by {doc.uploader?.first_name} {doc.uploader?.last_name}
+
+
+
+
+
+
openPreview(doc)}
+ class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ title="Preview"
+ >
+
+
+
+
+
+ {#if canDelete}
+
confirmDelete(doc)}
+ class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
+ title="Delete"
+ >
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateFolderModal}
+ (showCreateFolderModal = false)}
+ />
+{/if}
+
+
+{#if showPreviewModal && documentToPreview}
+ {
+ showPreviewModal = false;
+ documentToPreview = null;
+ }}
+ />
+{/if}
+
+
+{#if showUploadModal}
+
+
+
+
Upload Document
+
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ await invalidateAll();
+ isSubmitting = false;
+ if (result.type === 'success') {
+ resetUploadForm();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ {#if currentFolderId}
+
+ {/if}
+
+
+
+
+
+ {#if selectedFile}
+
+
+
+
{selectedFile.name}
+
{formatFileSize(selectedFile.size)}
+
+
{
+ e.stopPropagation();
+ selectedFile = null;
+ }}
+ class="rounded p-1 hover:bg-slate-100"
+ >
+
+
+
+ {:else}
+
+
+ Drag and drop or click to select
+
+
+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, GIF (max 50MB)
+
+ {/if}
+
+
+
+ Title *
+
+
+
+
+ Description
+
+
+
+
+
+ Category
+
+ Select category...
+ {#each categories as category}
+ {category.display_name}
+ {/each}
+
+
+
+
+ Visibility
+
+ Members Only
+ Board Only
+ Admin Only
+ Public
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Uploading...
+ {:else}
+
+ Upload Document
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showDeleteConfirm && documentToDelete}
+
+
+
+
+
+ Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
+
+
+
+
+ Are you sure you want to delete {documentToDelete.title || documentToDelete.name} ? This action cannot be undone.
+
+
+
+ {
+ 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
+
+
{
+ return async ({ update, result }) => {
+ if (result.type === 'success') {
+ showDeleteConfirm = false;
+ documentToDelete = null;
+ }
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="flex-1"
+ >
+
+
+ Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
+
+
+
+
+
+{/if}
+
+
+{#if showRenameFolderModal && renamingFolder}
+
+
+
+
Rename Folder
+ {
+ showRenameFolderModal = false;
+ renamingFolder = null;
+ }}
+ class="rounded p-1 hover:bg-slate-100"
+ >
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ await invalidateAll();
+ isSubmitting = false;
+ if (result.type === 'success') {
+ showRenameFolderModal = false;
+ renamingFolder = null;
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ Folder Name
+
+
+
+
+
{
+ 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
+
+
+ {#if isSubmitting}
+
+ Saving...
+ {:else}
+ Save
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/dues/+page.server.ts b/src/routes/(app)/board/dues/+page.server.ts
new file mode 100644
index 0000000..b6d6bb4
--- /dev/null
+++ b/src/routes/(app)/board/dues/+page.server.ts
@@ -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
+ };
+
+ 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 };
+ }
+};
diff --git a/src/routes/(app)/board/dues/+page.svelte b/src/routes/(app)/board/dues/+page.svelte
new file mode 100644
index 0000000..f787000
--- /dev/null
+++ b/src/routes/(app)/board/dues/+page.svelte
@@ -0,0 +1,679 @@
+
+
+
+ Dues Management | Monaco USA
+
+
+
+
+
+
Dues Management
+
Track and record membership dues payments
+
+
+
+
+ Reports
+
+
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"
+ >
+
+ Remind Due Soon ({stats.dueSoon})
+
+
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"
+ >
+
+ Remind Overdue ({stats.overdue})
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total
+
+
+
+
+
+
+
+
+
+
{stats.current}
+
Current
+
+
+
+
+
+
+
+
+
+
{stats.dueSoon}
+
Due Soon
+
+
+
+
+
+
+
+
{stats.overdue}
+
Overdue
+
+
+
+
+
+
+
+
+
+
{stats.neverPaid}
+
Never Paid
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Status
+ Current
+ Due Soon
+ Overdue
+ Never Paid
+
+
+
+
+ {#if members.length === 0}
+
+
+
No members found
+
Try adjusting your search or filters.
+
+ {:else}
+
+ {#each members as member}
+ {@const duesInfo = getDuesInfo(member.dues_status)}
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+ {member.first_name} {member.last_name}
+
+
{member.member_id}
+
+
+
+
+
+
+
+ {duesInfo.label}
+
+ {#if member.current_due_date}
+
+ {member.dues_status === 'overdue'
+ ? `${member.days_overdue} days overdue`
+ : `Due: ${formatDate(member.current_due_date)}`}
+
+ {/if}
+
+
+ {#if member.dues_status !== 'current' && member.dues_status !== 'never_paid'}
+
{
+ sendingReminderId = member.id;
+ return async ({ update }) => {
+ sendingReminderId = null;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ >
+
+
+ {#if sendingReminderId === member.id}
+
+ Sending...
+ {:else}
+
+ Remind
+ {/if}
+
+
+ {/if}
+
+
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"
+ >
+
+ Record
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Recent Payments
+
+
+
+ {#if recentPayments.length === 0}
+
+
No payments recorded yet.
+
+ {:else}
+
+ {#each recentPayments as payment}
+
+
+
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+
{payment.member?.member_id}
+
+
€{payment.amount.toFixed(2)}
+
+
+ {formatDate(payment.payment_date)}
+ by {payment.recorder?.first_name}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+{#if showPaymentModal && selectedMemberForPayment}
+
+
+
+
Record Payment
+
+
+
+
+
+
+
+ {#if selectedMemberForPayment.avatar_url}
+
+ {:else}
+
+ {selectedMemberForPayment.first_name?.[0]}{selectedMemberForPayment.last_name?.[0]}
+
+ {/if}
+
+
+ {selectedMemberForPayment.first_name} {selectedMemberForPayment.last_name}
+
+
+ {selectedMemberForPayment.member_id} •
+ {selectedMemberForPayment.membership_type_name || 'Regular Member'}
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ isSubmitting = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ setTimeout(closePaymentModal, 1500);
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ Amount (€)
+
+
+
+
+
+
+ Payment Method
+
+ Bank Transfer
+ Cash
+ Check
+ Other
+
+
+
+
+ Reference (optional)
+
+
+
+
+ Notes (optional)
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Recording...
+ {:else}
+
+ Record Payment
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showBulkModal && bulkActionType}
+
+
+
+
+ {bulkActionType === 'dueSoon' ? 'Send Due Soon Reminders' : 'Send Overdue Reminders'}
+
+
+
+
+
+
+ {#if bulkResult}
+
+
+
+
{bulkResult.success || bulkResult}
+
+
+ Close
+
+
+ {:else}
+
+
+
+
+ {#if bulkActionType === 'dueSoon'}
+
+ {:else}
+
+ {/if}
+
+
+ {bulkActionType === 'dueSoon' ? stats.dueSoon : stats.overdue} member(s) will receive reminders
+
+
+ {bulkActionType === 'dueSoon'
+ ? 'Members with dues due within 30 days'
+ : 'Members with overdue payments'}
+
+
+
+
+
+
+ This will send email reminders to all eligible members who haven't already received a reminder for their current dues period.
+
+
+
{
+ isBulkSubmitting = true;
+ return async ({ result, update }) => {
+ isBulkSubmitting = false;
+ if (result.type === 'success' && result.data) {
+ bulkResult = result.data;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+ Cancel
+
+
+ {#if isBulkSubmitting}
+
+ Sending...
+ {:else}
+
+ Send Reminders
+ {/if}
+
+
+
+
+ {/if}
+
+
+{/if}
+
+
+{#if form?.success && !showPaymentModal && !showBulkModal}
+
+{/if}
+
+{#if form?.error && !showPaymentModal && !showBulkModal}
+
+{/if}
diff --git a/src/routes/(app)/board/dues/reports/+page.server.ts b/src/routes/(app)/board/dues/reports/+page.server.ts
new file mode 100644
index 0000000..8f2a4cb
--- /dev/null
+++ b/src/routes/(app)/board/dues/reports/+page.server.ts
@@ -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;
+}
diff --git a/src/routes/(app)/board/dues/reports/+page.svelte b/src/routes/(app)/board/dues/reports/+page.svelte
new file mode 100644
index 0000000..b4ff149
--- /dev/null
+++ b/src/routes/(app)/board/dues/reports/+page.svelte
@@ -0,0 +1,346 @@
+
+
+
+ Dues Reports | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
Dues Reports
+
Analytics and financial reporting
+
+
+
+
+
+
+
+
+
+
Collected This Month
+
+ {formatCurrency(analytics?.totalCollectedThisMonth || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Collected This Year
+
+ {formatCurrency(analytics?.totalCollectedThisYear || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Outstanding
+
+ {formatCurrency(analytics?.totalOutstanding || 0)}
+
+
+
+
+
+
+
+
+
+
Reminders Sent
+
+ {analytics?.remindersSentThisMonth || 0}
+
+
This month
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Collection Trend (12 Months)
+
+
+
+
+ {#if analytics?.paymentsByMonth && analytics.paymentsByMonth.length > 0}
+
+ {#each analytics.paymentsByMonth as month, i}
+
+
+
+ {month.month.split(' ')[0].slice(0, 3)}
+
+
+ {/each}
+
+ {:else}
+
+
No payment data available
+
+ {/if}
+
+
+
+
+
+
+
+
+ Member Status Breakdown
+
+
+
+
+ {#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]}
+
+
+
+
+ {config.label}
+
+ {status.count} ({status.percentage.toFixed(1)}%)
+
+
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+ Total Members
+ {analytics?.totalMembers || 0}
+
+
+
+
+
+
+
+
+
+
+ Reminder Effectiveness
+
+
+
+
+
+
Total Reminders Sent
+
{effectiveness?.totalRemindersSent || 0}
+
+
+
Paid Within 7 Days
+
{effectiveness?.paidWithin7Days || 0}
+
+
+
Paid Within 30 Days
+
{effectiveness?.paidWithin30Days || 0}
+
+
+
Effectiveness Rate
+
+ {(effectiveness?.effectivenessRate || 0).toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+ Export Reports
+
+
Download detailed reports as CSV files
+
+
+
+
{
+ isExporting = 'members';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'members'}
+
+ {:else}
+
+ {/if}
+ All Members Report
+
+
+
+
{
+ isExporting = 'payments';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'payments'}
+
+ {:else}
+
+ {/if}
+ Payment History
+
+
+
+
{
+ isExporting = 'overdue';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'overdue'}
+
+ {:else}
+
+ {/if}
+ Overdue Members
+
+
+
+
+
diff --git a/src/routes/(app)/board/events/+page.server.ts b/src/routes/(app)/board/events/+page.server.ts
new file mode 100644
index 0000000..a70f45b
--- /dev/null
+++ b/src/routes/(app)/board/events/+page.server.ts
@@ -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!' };
+ }
+};
diff --git a/src/routes/(app)/board/events/+page.svelte b/src/routes/(app)/board/events/+page.svelte
new file mode 100644
index 0000000..9d88e83
--- /dev/null
+++ b/src/routes/(app)/board/events/+page.svelte
@@ -0,0 +1,580 @@
+
+
+
+ Event Management | Monaco USA
+
+
+
+
+
+
Event Management
+
Create and manage association events
+
+
+
+
+ Create Event
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Events
+
+
+
+
+
+
+
+
+
+
{stats.upcoming}
+
Upcoming
+
+
+
+
+
+
+
+
+
+
{stats.draft}
+
Drafts
+
+
+
+
+
+
+
+
+
+
{stats.past}
+
Past Events
+
+
+
+
+
+
+
+ updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Events
+ Published
+ Draft
+ Cancelled
+ Completed
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+ {#if events.length === 0}
+
+
+
No events found
+
+ {filters.status !== 'all'
+ ? 'Try adjusting your filters.'
+ : 'Create your first event to get started.'}
+
+
+ {:else}
+
+
+
+
+
+ Event
+
+
+ Date & Time
+
+
+ Attendees
+
+
+ Visibility
+
+
+ Status
+
+
+ Actions
+
+
+
+
+ {#each events as event}
+ {@const statusInfo = getStatusInfo(event.status)}
+ {@const eventPast = isPast(event.end_datetime)}
+ {
+ // 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"
+ >
+
+
+
{event.title}
+ {#if event.event_type_name}
+
+ {event.event_type_name}
+
+ {/if}
+
+
+
+
+
{formatDate(event.start_datetime)}
+
+ {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}
+
+
+
+
+
+
+ {event.total_attendees || 0}
+ {#if event.max_attendees}
+ / {event.max_attendees}
+ {/if}
+
+ {#if event.waitlist_count > 0}
+ {event.waitlist_count} on waitlist
+ {/if}
+
+
+
+ {getVisibilityLabel(event.visibility)}
+
+
+
+
+
+ {statusInfo.label}
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+
+ {#if event.status === 'draft'}
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateModal}
+
+
+
+
+
Create New Event
+ (showCreateModal = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ isSubmitting = false;
+ if (result.type === 'success') {
+ showCreateModal = false;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+ Event Title *
+
+
+
+
+ Description
+
+
+
+
+ Event Type
+
+ Select type...
+ {#each eventTypes as type}
+ {type.display_name}
+ {/each}
+
+
+
+
+ Location
+
+
+
+
+
+
+ Start Time *
+
+
+
+
+
+
+ End Time *
+
+
+
+
+ Max Attendees
+
+
+
+
+ Max Guests per Member
+
+
+
+
+
+
+ This is a paid event
+
+
+
+ {#if isPaid}
+
+ Member Price (€)
+
+
+
+
+ Non-Member Price (€)
+
+
+ {/if}
+
+
+ Visibility
+
+ Members Only
+ Public
+ Board Only
+ Admin Only
+
+
+
+
+ Status
+
+ Published
+ Draft
+
+
+
+
+
+
(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
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Event
+ {/if}
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.server.ts b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts
new file mode 100644
index 0000000..6ddb2d4
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts
@@ -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 = {
+ 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: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ You're Invited!
+ Dear ${invitee.first_name},
+ We'd love for you to join us at an upcoming Monaco USA event!
+
+
+
${event.title}
+
Date: ${eventDate}
+
Time: ${eventTime}
+ ${event.location ? `
Location: ${event.location}
` : ''}
+
+
+ ${event.description ? `${event.description.substring(0, 200)}${event.description.length > 200 ? '...' : ''}
` : ''}
+
+
+
+ Click the button above to view the event details and RSVP.
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+`,
+ 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.` };
+ }
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.svelte b/src/routes/(app)/board/events/[id]/attendees/+page.svelte
new file mode 100644
index 0000000..ffc2297
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/attendees/+page.svelte
@@ -0,0 +1,919 @@
+
+
+
+ Attendees: {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+
+
(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"
+ >
+
+ Invite Members
+
+
+ Edit Event
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {/if}
+
+ {#if event}
+
+
+
+
+
+ {formatDate(event.start_datetime)}
+ {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}
+
+ {#if event.location}
+
+
+ {event.location}
+
+ {/if}
+
+
+ {event.total_attendees}{event.max_attendees ? ` / ${event.max_attendees}` : ''} attendees
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.confirmed}
+
Confirmed
+
+
+
+ {#if event?.is_paid}
+
+
+
+
+
+
+
{pendingPaymentsCount}
+
Pending Payment
+
+
+
+ {/if}
+
+
+
+
+
+
+
{stats.waitlist}
+
Waitlist
+
+
+
+
+
+
+
+
+
+
{stats.maybe}
+
Maybe
+
+
+
+
+
+
+
+
+
+
{stats.declined}
+
Declined
+
+
+
+
+
+
+
+
+
+
{stats.attended}
+
Checked In
+
+
+
+
+
+
+
+ {#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}
+ 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}
+
+ {/each}
+
+
+
+ {#if memberRsvps.length > 0}
+
+
+
Members ({stats.totalMembers})
+
+
+ {#each filterRsvps(memberRsvps, statusFilter) as rsvp}
+
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ {#if rsvp.attended}
+
+ {/if}
+
+
+
+
+
+
+
+ {rsvp.member?.first_name} {rsvp.member?.last_name}
+
+
+ {rsvp.status}
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
+
+ Payment Pending
+
+ {:else if event?.is_paid && rsvp.payment_status === 'paid'}
+
+
+ Paid
+
+ {/if}
+
+
{rsvp.member?.member_id}
+
+
+
+
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark Paid
+
+
+ {/if}
+
+
+ toggleExpand(rsvp.id)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+ {#if expandedRsvp === rsvp.id}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if expandedRsvp === rsvp.id}
+
+
+ {#if rsvp.member?.email}
+
+
+ {rsvp.member.email}
+
+ {/if}
+ {#if rsvp.member?.phone}
+
+
+ {rsvp.member.phone}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_amount}
+
+ Amount: €{rsvp.payment_amount.toFixed(2)}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark as Paid
+
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Promote to Confirmed
+
+
+ {/if}
+ {
+ 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();
+ };
+ }}
+ >
+
+
+
+
+ Remove RSVP
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if publicRsvps.length > 0}
+
+
+
Non-Members ({stats.totalNonMembers})
+
+
+ {#each filterRsvps(publicRsvps, statusFilter) as rsvp}
+
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ {#if rsvp.attended}
+
+ {/if}
+
+
+
+
+
+
+
+ {rsvp.full_name}
+
+
+ Guest
+
+
+ {rsvp.status}
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
+
+ Payment Pending
+
+ {:else if event?.is_paid && rsvp.payment_status === 'paid'}
+
+
+ Paid
+
+ {/if}
+
+
{rsvp.email}
+
+
+
+
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark Paid
+
+
+ {/if}
+
+
+ toggleExpand(`public-${rsvp.id}`)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+ {#if expandedRsvp === `public-${rsvp.id}`}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if expandedRsvp === `public-${rsvp.id}`}
+
+
+
+
+ {#if event?.is_paid && rsvp.payment_amount}
+
+ Amount: €{rsvp.payment_amount.toFixed(2)}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark as Paid
+
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Promote to Confirmed
+
+
+ {/if}
+ {
+ 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();
+ };
+ }}
+ >
+
+
+
+
+ Remove RSVP
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if memberRsvps.length === 0 && publicRsvps.length === 0}
+
+
+
No RSVPs yet
+
No one has RSVP'd to this event yet.
+
+ {/if}
+ {:else}
+
+
Event not found.
+
+ View all events
+
+
+ {/if}
+
+
+
+{#if showInviteModal}
+
+
+
+
+
+
Invite Members to Event
+
+
+
+
+
+
+
+ Select members to send an email invitation with a link to RSVP for "{event?.title}".
+
+
+
+
+
+
+
+
+ {#if uninvitedMembers.length === 0}
+
+
+
All members have already been invited or RSVPed.
+
+ {:else}
+
+
+
+ {selectedMembers.size === filteredUninvitedMembers.length ? 'Deselect All' : 'Select All'}
+
+
+ {selectedMembers.size} of {filteredUninvitedMembers.length} selected
+
+
+
+
+
+ {#each filteredUninvitedMembers as member (member.id)}
+
+ toggleMemberSelection(member.id)}
+ class="h-4 w-4 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500"
+ />
+
+
+ {member.first_name} {member.last_name}
+
+
{member.email}
+
+ {member.member_id}
+
+ {:else}
+
+ No members match your search.
+
+ {/each}
+
+ {/if}
+
+
+
+
+ Cancel
+
+
{
+ 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}
+
+ {/each}
+
+ {#if inviteLoading}
+
+
+ Sending...
+
+ {:else}
+ Send {selectedMembers.size} Invitation{selectedMembers.size !== 1 ? 's' : ''}
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/edit/+page.server.ts b/src/routes/(app)/board/events/[id]/edit/+page.server.ts
new file mode 100644
index 0000000..9702e43
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/edit/+page.server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/edit/+page.svelte b/src/routes/(app)/board/events/[id]/edit/+page.svelte
new file mode 100644
index 0000000..6551aa1
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/edit/+page.svelte
@@ -0,0 +1,416 @@
+
+
+
+ Edit Event | Monaco USA
+
+
+
+
+
+
+ Back to events
+
+
+
+
+
Edit Event
+
Update event details and settings
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+ Event Details
+
+
+
+
+ Event Title *
+
+
+
+
+ Description
+ {event.description || ''}
+
+
+
+ Event Type
+
+ Select type...
+ {#each eventTypes as type}
+ {type.display_name}
+ {/each}
+
+
+
+
+ Status
+
+ Published
+ Draft
+ Cancelled
+ Completed
+
+
+
+
+
+
+
+
Date & Time
+
+
+
+
+
+ Start Time *
+
+
+
+
+
+
+ End Time *
+
+
+
+
+
+
+
+
+
+
+
+
+ Capacity
+
+
+
+
+
Max Attendees
+
+
Leave empty for unlimited
+
+
+
+ Max Guests per Member
+
+
+
+
+
+
+
+
+
+ Pricing
+
+
+
+
+ This is a paid event
+
+
+ {#if isPaid}
+
+ {/if}
+
+
+
+
+
+
+ Visibility
+
+
+
+ Who can see this event?
+
+ Members Only
+ Public (Anyone)
+ Board Only
+ Admin Only
+
+
+
+
+
+
+ {#if member?.role === 'admin'}
+
(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"
+ >
+
+ Delete Event
+
+ {:else}
+
+ {/if}
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Saving...
+ {:else}
+
+ Save Changes
+ {/if}
+
+
+
+
+
+
+
+
+{#if showDeleteConfirm}
+
+
+
+
Delete Event
+ (showDeleteConfirm = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
+ Are you sure you want to delete {event.title} ? This action cannot be undone.
+
+
+
+ All RSVPs and associated data will also be deleted.
+
+
+
+ (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
+
+
+
+ Delete Event
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
new file mode 100644
index 0000000..a1e74ed
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
@@ -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 = {
+ 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 = {
+ 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 };
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.svelte b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte
new file mode 100644
index 0000000..eda6f21
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte
@@ -0,0 +1,513 @@
+
+
+
+ Roll Call: {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+
+ {event?.title || 'Roll Call'}
+
+
+ {#if event?.start_datetime}
+ {formatEventDate(event.start_datetime)}
+ {/if}
+ {#if event?.location}
+ • {event.location}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
{stats.checkedIn}
+
Checked In
+
+
+
+
{stats.confirmed}
+
Confirmed
+
+ {#if stats.waitlist > 0}
+
+
+
{stats.waitlist}
+
Waitlist
+
+ {/if}
+
+
+
+
+
+
+
+ {#if searchQuery}
+ 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"
+ >
+
+
+ {/if}
+
+
+
+
+
+ {#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}
+ 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}
+
+ {tab.count}
+
+
+ {/each}
+
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if filteredRsvps().length === 0}
+
+
+
+ {searchQuery ? 'No attendees match your search' : 'No attendees to show'}
+
+
+ {:else}
+
+ {#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}
+
+
{
+ return async ({ update }) => {
+ await invalidateAll();
+ await update({ reset: false });
+ };
+ }}
+ >
+
+
+
+
+
+
+
+ {#if avatarUrl}
+
+ {:else}
+
+ {getInitials(isMember ? rsvp.member?.first_name : rsvp.full_name?.split(' ')[0] || '', isMember ? rsvp.member?.last_name : rsvp.full_name?.split(' ')[1] || '')}
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+
+ WL
+
+ {/if}
+
+
+
+
+
+
{name}
+ {#if !isMember}
+
+ Guest
+
+ {/if}
+
+ {#if memberId}
+
{memberId}
+ {:else if !isMember && rsvp.email}
+
{rsvp.email}
+ {/if}
+ {#if rsvp.guest_count > 0}
+
+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+ {/if}
+
+
+
+
+ {#if rsvp.attended}
+
+ {:else}
+ +
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
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"
+ >
+
+ Walk-in
+
+
+
+
+{#if showWalkInModal}
+
+
+
+
+
Add Walk-in
+
+
+
+
+
+
+
+ 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
+
+ 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
+
+
+
+
+
+ {#if walkInTab === 'member'}
+
+
+
+
+
+
+ {#if filteredWalkInMembers.length === 0}
+
+ {walkInMemberSearch ? 'No members found' : 'All members have already RSVPed'}
+
+ {:else}
+
+ {#each filteredWalkInMembers as member (member.id)}
+
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}
+
+ {:else}
+
+ {getInitials(member.first_name, member.last_name)}
+
+ {/if}
+
+
{member.first_name} {member.last_name}
+
{member.member_id}
+
+ {#if selectedWalkInMember === member.id}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {#if selectedWalkInMember}
+
{
+ walkInLoading = true;
+ return async ({ update, result }) => {
+ walkInLoading = false;
+ if (result.type === 'success') {
+ closeWalkInModal();
+ await invalidateAll();
+ }
+ await update({ reset: false });
+ };
+ }}
+ class="mt-4"
+ >
+
+
+ {walkInLoading ? 'Adding...' : 'Add & Check In'}
+
+
+ {/if}
+ {:else}
+
+
{
+ walkInLoading = true;
+ return async ({ update, result }) => {
+ walkInLoading = false;
+ if (result.type === 'success') {
+ closeWalkInModal();
+ await invalidateAll();
+ }
+ await update({ reset: false });
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+ Guest Name *
+
+
+
+
+
+ Email (optional)
+
+
+
+
+ {walkInLoading ? 'Adding...' : 'Add Guest & Check In'}
+
+
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/src/routes/(app)/board/members/+page.server.ts b/src/routes/(app)/board/members/+page.server.ts
new file mode 100644
index 0000000..0b06b0e
--- /dev/null
+++ b/src/routes/(app)/board/members/+page.server.ts
@@ -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
+ }
+ };
+};
diff --git a/src/routes/(app)/board/members/+page.svelte b/src/routes/(app)/board/members/+page.svelte
new file mode 100644
index 0000000..de21553
--- /dev/null
+++ b/src/routes/(app)/board/members/+page.svelte
@@ -0,0 +1,374 @@
+
+
+
+ Members Directory | Monaco USA
+
+
+
+
+
+
Members Directory
+
View and manage association members
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Members
+
+
+
+
+
+
+
+
+
+
{stats.active}
+
Active
+
+
+
+
+
+
+
+
+
+
{stats.pending}
+
Pending
+
+
+
+
+
+
+
+
+
+
{stats.inactive}
+
Inactive
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
(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"
+ >
+
+ Filters
+
+
+
+
+ {#if showFilters}
+
+
+ Status
+ updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Statuses
+ {#each statuses as status}
+ {status.display_name}
+ {/each}
+
+
+
+
+ Role
+ updateFilters({ role: roleFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Roles
+ Member
+ Board
+ Admin
+
+
+
+ {/if}
+
+
+
+
+ {#if members.length === 0}
+
+
+
No members found
+
+ {filters.search || filters.status !== 'all' || filters.role !== 'all'
+ ? 'Try adjusting your search or filters.'
+ : 'Members will appear here when added.'}
+
+
+ {:else}
+
+
+
+
+
+ Member
+
+
+ Contact
+
+
+ Status
+
+
+ Dues
+
+
+ Member Since
+
+
+ Actions
+
+
+
+
+ {#each members as member}
+ {@const statusInfo = getStatusInfo(member.status_name)}
+ {@const duesInfo = getDuesInfo(member.dues_status)}
+ {@const roleBadge = getRoleBadge(member.role)}
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+
+ {member.first_name} {member.last_name}
+
+
+ {roleBadge.label}
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
{member.member_id}
+
+
+
+
+
+
+ {#if member.phone}
+
+ {/if}
+
+
+
+
+
+ {member.status_display_name || member.status_name || 'Unknown'}
+
+
+
+
+
+ {duesInfo.label}
+
+ {#if member.current_due_date}
+
+ Due: {formatDate(member.current_due_date)}
+
+ {/if}
+
+
+
+ {formatDate(member.member_since)}
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/src/routes/(app)/board/reports/+page.server.ts b/src/routes/(app)/board/reports/+page.server.ts
new file mode 100644
index 0000000..34f3055
--- /dev/null
+++ b/src/routes/(app)/board/reports/+page.server.ts
@@ -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,
+ 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,
+ byMethod: {} as Record
+ };
+
+ // 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
+ };
+
+ // 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 };
+ }
+};
diff --git a/src/routes/(app)/board/reports/+page.svelte b/src/routes/(app)/board/reports/+page.svelte
new file mode 100644
index 0000000..67e9e7d
--- /dev/null
+++ b/src/routes/(app)/board/reports/+page.svelte
@@ -0,0 +1,511 @@
+
+
+
+ Reports | Monaco USA
+
+
+
+
+
+
+
Reports
+
Generate and export membership, dues, and event reports
+
+
+
+
+ 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}
+ {y}
+ {/each}
+
+
+
+ exportToCsv(reportType)}>
+
+ Export CSV
+
+
+
+
+
+
+ {#each reportTabs as tab}
+ 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.label}
+
+ {/each}
+
+
+
+ {#if reportType === 'membership'}
+
+
+
+
+
+
+
Total Members
+
{membershipStats.total}
+
+
+
+
+
+
+
+
+
Current Dues
+
{membershipStats.byDuesStatus.current}
+
+
+
+
+
+
+
+
+
Overdue
+
{membershipStats.byDuesStatus.overdue}
+
+
+
+
+
+
+
+
+
Never Paid
+
{membershipStats.byDuesStatus.never_paid}
+
+
+
+
+
+
+
+
+
+
+
Members by Role
+
+
+ Administrators
+ {membershipStats.byRole.admin}
+
+
+ Board Members
+ {membershipStats.byRole.board}
+
+
+ Regular Members
+ {membershipStats.byRole.member}
+
+
+
+
+
+
+
Members by Status
+
+ {#each Object.entries(membershipStats.byStatus) as [status, count]}
+
+ {status}
+ {count}
+
+ {/each}
+
+
+
+
+
+
+
+
All Members ({members.length})
+
+
+
+
+
+ Member
+ ID
+ Status
+ Dues
+ Since
+
+
+
+ {#each members.slice(0, 20) as member}
+
+
+
+
{member.first_name} {member.last_name}
+
{member.email}
+
+
+ {member.member_id}
+
+
+ {member.status_display_name || 'Unknown'}
+
+
+
+
+ {member.dues_status.replace('_', ' ')}
+
+
+ {formatDate(member.member_since)}
+
+ {/each}
+
+
+
+ {#if members.length > 20}
+
+ Showing 20 of {members.length} members. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if reportType === 'dues'}
+
+
+
+
+
+
+
Total Collected ({year})
+
{formatCurrency(duesStats.totalCollected)}
+
+
+
+
+
+
+
+
+
Payments
+
{duesStats.paymentCount}
+
+
+
+
+
+
+
+
+
Avg. Payment
+
+ {formatCurrency(duesStats.paymentCount > 0 ? duesStats.totalCollected / duesStats.paymentCount : 0)}
+
+
+
+
+
+
+
+
+
+
Collection Rate
+
+ {Math.round((membershipStats.byDuesStatus.current / membershipStats.total) * 100)}%
+
+
+
+
+
+
+
+
+
+
+
+
Collection by Month
+
+ {#each Object.entries(duesStats.byMonth).slice(0, 12) as [month, data]}
+
+
{month}
+
+ {formatCurrency(data.amount)}
+ ({data.count})
+
+
+ {/each}
+
+
+
+
+
+
Collection by Payment Method
+
+ {#each Object.entries(duesStats.byMethod) as [method, data]}
+
+
{method.replace('_', ' ')}
+
+ {formatCurrency(data.amount)}
+ ({data.count})
+
+
+ {/each}
+
+
+
+
+
+
+
+
All Payments ({payments.length})
+
+
+
+
+
+ Date
+ Member
+ Amount
+ Method
+ Reference
+
+
+
+ {#each payments.slice(0, 20) as payment}
+
+ {formatDate(payment.payment_date)}
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+ {formatCurrency(payment.amount)}
+ {payment.payment_method?.replace('_', ' ')}
+ {payment.reference || '-'}
+
+ {/each}
+
+
+
+ {#if payments.length > 20}
+
+ Showing 20 of {payments.length} payments. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if reportType === 'events'}
+
+
+
+
+
+
+
Total Events ({year})
+
{eventStats.totalEvents}
+
+
+
+
+
+
+
+
+
Total Attendees
+
{eventStats.totalAttendees}
+
+
+
+
+
+
+
+
+
Avg. Attendance
+
{eventStats.averageAttendance}
+
+
+
+
+
+
+
+
+
Event Types
+
{Object.keys(eventStats.byType).length}
+
+
+
+
+
+
+
+
+
Attendance by Event Type
+
+ {#each Object.entries(eventStats.byType) as [type, data]}
+
+
{type}
+
+ {data.count} events
+ {data.attendees} attendees
+
+
+ {/each}
+
+
+
+
+
+
+
All Events ({events.length})
+
+
+
+
+
+ Date
+ Event
+ Type
+ Attendees
+ Capacity
+ Waitlist
+
+
+
+ {#each events.slice(0, 20) as event}
+
+ {formatDate(event.start_datetime)}
+ {event.title}
+
+
+ {event.event_type_name || 'General'}
+
+
+ {event.total_attendees}
+ {event.max_attendees || 'Unlimited'}
+ {event.waitlist_count}
+
+ {/each}
+
+
+
+ {#if events.length > 20}
+
+ Showing 20 of {events.length} events. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
diff --git a/src/routes/(app)/dashboard/+page.server.ts b/src/routes/(app)/dashboard/+page.server.ts
new file mode 100644
index 0000000..63b8887
--- /dev/null
+++ b/src/routes/(app)/dashboard/+page.server.ts
@@ -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'];
+ }
+}
diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte
new file mode 100644
index 0000000..0188d8c
--- /dev/null
+++ b/src/routes/(app)/dashboard/+page.svelte
@@ -0,0 +1,110 @@
+
+
+
+ Dashboard | Monaco USA
+
+
+
+
+ {#if member}
+
+ {/if}
+
+
+ {#if member}
+
+ {/if}
+
+
+
+
+ {#if member}
+
+ {/if}
+
+
+
+
+
+
+ {#if isBoard && stats}
+
+
Board Overview
+
+
+
+
+
+
+
+ {/if}
+
+
+ {#if isAdmin && stats}
+
+
Admin Overview
+
+
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/documents/+page.server.ts b/src/routes/(app)/documents/+page.server.ts
new file mode 100644
index 0000000..9bce5ae
--- /dev/null
+++ b/src/routes/(app)/documents/+page.server.ts
@@ -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'];
+ }
+}
diff --git a/src/routes/(app)/documents/+page.svelte b/src/routes/(app)/documents/+page.svelte
new file mode 100644
index 0000000..b5fbe80
--- /dev/null
+++ b/src/routes/(app)/documents/+page.svelte
@@ -0,0 +1,270 @@
+
+
+
+ Documents | Monaco USA
+
+
+
+
+
+
Documents
+
Meeting minutes, bylaws, and resources
+
+
+
+
+ (viewMode = 'grid')}
+ class="rounded-md p-1.5 {viewMode === 'grid' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
+ >
+
+
+ (viewMode = 'list')}
+ class="rounded-md p-1.5 {viewMode === 'list' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (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
+
+ {#each categories || [] as category}
+ (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}
+
+ {/each}
+
+
+
+
+ {#if filteredDocuments.length === 0}
+
+
+
No documents found
+
+ {searchQuery || selectedCategory
+ ? 'Try adjusting your search or filters.'
+ : 'Documents will appear here when added.'}
+
+
+ {:else if viewMode === 'grid'}
+
+ {#each filteredDocuments as doc}
+ {@const category = getCategory(doc.category_id)}
+
+
+
+
+
+ {getFileIcon(doc.mime_type)}
+
+ {#if category}
+
+ {category.display_name}
+
+ {/if}
+
+
+
+
+
{doc.title}
+ {#if doc.description}
+
{doc.description}
+ {/if}
+
+
+
+
+ {formatDate(doc.created_at)}
+
+ {formatFileSize(doc.file_size)}
+
+
+
+
+
+ {/each}
+
+ {:else}
+
+
+
+
+
+
+ Document
+
+
+ Category
+
+
+ Date
+
+
+ Size
+
+
+ Actions
+
+
+
+
+ {#each filteredDocuments as doc}
+ {@const category = getCategory(doc.category_id)}
+
+
+
+
+
+
{doc.title}
+
{doc.file_name}
+
+
+
+
+ {category?.display_name || '-'}
+
+
+ {formatDate(doc.created_at)}
+
+
+ {formatFileSize(doc.file_size)}
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/events/+page.server.ts b/src/routes/(app)/events/+page.server.ts
new file mode 100644
index 0000000..175d1ac
--- /dev/null
+++ b/src/routes/(app)/events/+page.server.ts
@@ -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'];
+ }
+}
diff --git a/src/routes/(app)/events/+page.svelte b/src/routes/(app)/events/+page.svelte
new file mode 100644
index 0000000..ce3777b
--- /dev/null
+++ b/src/routes/(app)/events/+page.svelte
@@ -0,0 +1,340 @@
+
+
+
+ Events | Monaco USA
+
+
+
+
+
+
+
Events
+
Upcoming events and activities
+
+
+
+
+ (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
+
+ (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'}"
+ >
+
+ Calendar
+
+
+
+
+
+ {#if viewMode === 'list'}
+
+
+ {#if groupedEvents.length === 0}
+
+
+
No upcoming events
+
Check back later for new events and activities.
+
+ {:else}
+ {#each groupedEvents as group}
+ {@const groupDate = new Date(group.date)}
+
+
+
+
+
+ {groupDate.toLocaleDateString('en-US', { month: 'short' })}
+
+
+ {groupDate.getDate()}
+
+
+
+
+ {isToday(group.date)
+ ? 'Today'
+ : groupDate.toLocaleDateString('en-US', { weekday: 'long' })}
+
+
+ {groupDate.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })}
+
+
+
+
+
+
+
+ {/each}
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+
+
{monthName}
+
+
+
+
+
+
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+
+
+ {#each calendarDays as day}
+ {@const dayEvents = getEventsForDate(day)}
+
+ {#if day}
+
+ {day.getDate()}
+
+ {#each dayEvents.slice(0, 2) as event}
+
+ {event.title}
+
+ {/each}
+ {#if dayEvents.length > 2}
+
+{dayEvents.length - 2} more
+ {/if}
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/events/[id]/+page.server.ts b/src/routes/(app)/events/[id]/+page.server.ts
new file mode 100644
index 0000000..e772021
--- /dev/null
+++ b/src/routes/(app)/events/[id]/+page.server.ts
@@ -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 = {
+ 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'];
+ }
+}
diff --git a/src/routes/(app)/events/[id]/+page.svelte b/src/routes/(app)/events/[id]/+page.svelte
new file mode 100644
index 0000000..0862ff9
--- /dev/null
+++ b/src/routes/(app)/events/[id]/+page.svelte
@@ -0,0 +1,483 @@
+
+
+
+ {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+ Back to events
+
+
+ {#if event}
+
+
+ {#if event.cover_image_url}
+
+
+
+ {/if}
+
+
+
+
+ {event.event_type_name || 'Event'}
+
+ {#if event.is_paid}
+
+
+ €{event.member_price}
+
+ {:else}
+
+ Free
+
+ {/if}
+ {#if event.is_full}
+
+ Full
+
+ {/if}
+ {#if isPast}
+
+ Past Event
+
+ {/if}
+
+
+
{event.title}
+
+ {#if event.description}
+
{event.description}
+ {/if}
+
+
+
+
+
+
+
+
+
+
Date & Time
+ {#if event && !isPast}
+
+ {/if}
+
+
+
+
+
+
{startDateTime.date}
+
+ {startDateTime.time} - {endDateTime.time}
+
+
+
+ {#if event.location}
+
+ {/if}
+
+
+
+
+
+
Attendees
+
+
+
+
+ {event.total_attendees}
+ {event.max_attendees ? ` / ${event.max_attendees}` : ''} attending
+
+ {#if event.waitlist_count > 0}
+
{event.waitlist_count} on waitlist
+ {/if}
+
+
+
+
+
+
+
+
+
RSVP
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+ {#if form?.success}
+
+
+
+ {/if}
+
+ {#if isPast}
+
This event has already ended.
+ {:else if rsvp}
+
+
+
+ {#if isPendingPayment}
+
+ Processing - Payment Required
+ {:else if rsvp.status === 'confirmed'}
+
+ You're attending!
+ {:else if rsvp.status === 'waitlist'}
+
+ You're on the waitlist
+ {:else if rsvp.status === 'maybe'}
+
+ You're tentative
+ {:else if rsvp.status === 'declined'}
+
+ You declined
+ {/if}
+
+ {#if isPendingPayment && rsvp.payment_amount}
+
+
+ Amount Due:
+ €{rsvp.payment_amount.toFixed(2)}
+
+
+ Your RSVP will be confirmed once payment is received.
+
+
+ {/if}
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+
+ {#if rsvp.status !== 'cancelled'}
+
+
Change your response:
+
+ {#if rsvp.status !== 'confirmed' && !event.is_full}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Going
+
+
+ {/if}
+ {#if rsvp.status !== 'maybe'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Maybe
+
+
+ {/if}
+ {#if rsvp.status !== 'declined'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Can't Go
+
+
+ {/if}
+
+
+
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+ {#if loading}
+
+ {/if}
+ Remove my RSVP
+
+
+
+ {/if}
+ {:else}
+
+
{
+ 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}
+
+
Additional guests
+
+
+ Max {event.max_guests_per_member} guest{event.max_guests_per_member > 1
+ ? 's'
+ : ''} allowed
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+ Total: €{(event.member_price * (1 + guestCount)).toFixed(2)}
+
+
+ Payment instructions will be sent after RSVP
+
+
+ {/if}
+
+
+ {#if loading}
+
+ Submitting...
+ {:else if event.is_full}
+ Join Waitlist
+ {:else}
+
+ RSVP Now
+ {/if}
+
+
+ {/if}
+
+
+
+ {:else}
+
+
Event not found.
+
+ View all events
+
+
+ {/if}
+
diff --git a/src/routes/(app)/payments/+page.server.ts b/src/routes/(app)/payments/+page.server.ts
new file mode 100644
index 0000000..de90623
--- /dev/null
+++ b/src/routes/(app)/payments/+page.server.ts
@@ -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 = {};
+ 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
+ };
+};
diff --git a/src/routes/(app)/payments/+page.svelte b/src/routes/(app)/payments/+page.svelte
new file mode 100644
index 0000000..417d4dd
--- /dev/null
+++ b/src/routes/(app)/payments/+page.svelte
@@ -0,0 +1,286 @@
+
+
+
+ Payments | Monaco USA
+
+
+
+
+
Payments
+
View your dues status and payment history
+
+
+
+
+
+
+
+
+ Dues Status
+
+
+
+
+
+
{duesInfo.label}
+
{duesInfo.description}
+
+
+
+
+
+
Annual Dues
+
+ €{(member?.annual_dues || 50).toFixed(2)}
+
+
+
+
Membership Type
+
+ {member?.membership_type_name || 'Regular'}
+
+
+
+
Last Payment
+
+ {formatDate(member?.last_payment_date)}
+
+
+
+
Next Due Date
+
+ {formatDate(member?.current_due_date)}
+
+
+
+
+
+
+
+
+
+
+
+ Payment Details
+
+
+
+ Please make your payment via bank transfer to:
+
+
+
+ {#if paymentSettings?.bank_name}
+
+
Bank
+
{paymentSettings.bank_name}
+
+ {/if}
+
+
+
Account Holder
+
+ {paymentSettings?.account_holder || 'ASSOCIATION MONACO USA'}
+
+
+
+
+
IBAN
+
+
+ {paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860'}
+
+
+ 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'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Reference
+
+
+ {member?.member_id}
+
+ 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'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ Please include your Member ID ({member?.member_id}) in the payment reference.
+
+
+
+
+
+
+
+
+
+
+ Payment History
+
+
+
+ {#if payments && payments.length > 0}
+
+
+
+
+
+ Date
+
+
+ Amount
+
+
+ Period Covered
+
+
+ Reference
+
+
+ Method
+
+
+
+
+ {#each payments as payment}
+
+
+ {formatDate(payment.payment_date)}
+
+
+ €{payment.amount.toFixed(2)}
+
+
+ Until {formatDate(payment.due_date)}
+
+
+ {payment.reference || '-'}
+
+
+ {payment.payment_method?.replace('_', ' ') || 'Bank Transfer'}
+
+
+ {/each}
+
+
+
+ {:else}
+
+
+
No payments yet
+
+ Your payment history will appear here once you make your first payment.
+
+
+ {/if}
+
+
diff --git a/src/routes/(app)/profile/+page.server.ts b/src/routes/(app)/profile/+page.server.ts
new file mode 100644
index 0000000..abb2668
--- /dev/null
+++ b/src/routes/(app)/profile/+page.server.ts
@@ -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!' };
+ }
+};
diff --git a/src/routes/(app)/profile/+page.svelte b/src/routes/(app)/profile/+page.svelte
new file mode 100644
index 0000000..039c996
--- /dev/null
+++ b/src/routes/(app)/profile/+page.svelte
@@ -0,0 +1,374 @@
+
+
+
+ My Profile | Monaco USA
+
+
+
+
+
+
+
+
+ {#if member?.avatar_url}
+
+ {:else}
+
+ {member?.first_name[0]}{member?.last_name[0]}
+
+ {/if}
+
+
+
{
+ avatarLoading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ avatarLoading = false;
+ await update();
+ };
+ }}
+ class="absolute bottom-0 right-0"
+ >
+ e.currentTarget.form?.requestSubmit()}
+ class="hidden"
+ />
+
+ {#if avatarLoading}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if member?.avatar_url}
+
{
+ avatarLoading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ avatarLoading = false;
+ await update();
+ };
+ }}
+ class="absolute -bottom-2 -right-2"
+ >
+
+
+
+
+ {/if}
+
+
+
+
+ {member?.first_name}
+ {member?.last_name}
+
+
{member?.email}
+
+
+ {member?.member_id}
+
+
+ {member?.status_display_name || 'Pending'}
+
+ {#if member?.role !== 'member'}
+
+ {member?.role}
+
+ {/if}
+
+
+
+
+
+
+
+
Personal Information
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+ {#if form?.success}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Street address
+
+
+
+
+
+
+ City
+
+
+
+ Country of residence
+
+
+
+
+
+
+
+
+
+ Nationality
+
+
+
+
+
+ {#if loading}
+
+ Saving...
+ {:else}
+
+ Save changes
+ {/if}
+
+
+
+
+
+
+
+
Membership Details
+
+
+
+
Member ID
+ {member?.member_id}
+
+
+
Membership Type
+ {member?.membership_type_name || 'Regular'}
+
+
+
Member Since
+
+ {member?.member_since
+ ? new Date(member.member_since).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
Date of Birth
+
+ {member?.date_of_birth
+ ? new Date(member.date_of_birth).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
+
diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts
new file mode 100644
index 0000000..b45e978
--- /dev/null
+++ b/src/routes/(app)/settings/+page.server.ts
@@ -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 = `
+ Hi ${member.first_name},
+ Your Monaco USA account password was successfully changed on ${changedAt} .
+
+
⚠️ Didn't make this change?
+
If you did not change your password, please contact us immediately at info@monacousa.org .
+
+ This is an automated security notification.
`;
+
+ 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!' };
+ }
+};
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte
new file mode 100644
index 0000000..411f83a
--- /dev/null
+++ b/src/routes/(app)/settings/+page.svelte
@@ -0,0 +1,1186 @@
+
+
+
+
+
+ Settings | Monaco USA
+
+
+
+
+
Settings
+
Manage your profile, notifications, and account security
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+ {#each tabs as tab}
+ {@const Icon = tab.icon}
+ (activeTab = tab.id)}
+ class="flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab ===
+ tab.id
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+
+ {tab.label}
+
+ {/each}
+
+
+
+
+ {#if activeTab === 'profile'}
+
+
+
+
Profile Picture
+
+
+ {#if avatarPreview || member?.avatar_url}
+
+ {:else}
+
+ {getInitials(member?.first_name || '', member?.last_name || '')}
+
+ {/if}
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ avatarPreview = null;
+ };
+ }}
+ >
+
+ {#if avatarPreview}
+
+ {isSubmitting ? 'Uploading...' : 'Save Photo'}
+
+ {:else}
+
+
+ Change
+
+ {/if}
+
+
+ {#if member?.avatar_url}
+ {
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+ Remove
+
+
+ {/if}
+
+
+ JPG, PNG, WebP or GIF. Max 5MB.
+
+
+
+
+
+
+
Personal Information
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
Nationality
+
Select all nationalities/citizenships that apply
+
+
+
+ {#if selectedNationalities.length > 0}
+
+ {#each selectedNationalities as code}
+ {@const lowerCode = code.toLowerCase()}
+
+
+ {getCountryName(code)}
+ removeNationality(code)}
+ class="ml-1 rounded-full p-0.5 hover:bg-monaco-200"
+ >
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
(nationalityDropdownOpen = !nationalityDropdownOpen)}
+ class="flex w-full items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left text-sm transition-colors hover:border-slate-300 focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
+ >
+
+ {selectedNationalities.length === 0
+ ? 'Select nationalities...'
+ : `${selectedNationalities.length} selected`}
+
+
+
+
+ {#if nationalityDropdownOpen}
+
+
+
+
+
+
+ {#each filteredCountries as country}
+
toggleNationality(country.code)}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-slate-50 {selectedNationalities.includes(country.code) ? 'bg-monaco-50 text-monaco-700' : 'text-slate-700'}"
+ >
+
+ {country.name}
+ {#if selectedNationalities.includes(country.code)}
+ ✓
+ {/if}
+
+ {/each}
+ {#if filteredCountries.length === 0}
+
No countries found
+ {/if}
+
+
+ {/if}
+
+
+
+
+
+
+ Street Address
+
+
+
+
+
+ City
+
+
+
+ Country of Residence
+
+
+
+
+
+
+
+
+
+ {isSubmitting ? 'Saving...' : 'Save Changes'}
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'notifications'}
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+
+
+
Event Notifications
+
Control emails about events and RSVPs
+
+
+
+
+
+
+
RSVP Confirmations
+
Receive confirmation when you RSVP to an event
+
+
+
+
+
+
+
Event Reminders
+
Get reminded before events you're attending
+
+
+
+
+
+
+
Event Updates
+
Be notified of changes to events you're attending
+
+
+
+
+
+
+
Waitlist Promotions
+
Get notified when you're promoted from a waitlist
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Membership & Payments
+
Notifications about your membership and dues
+
+
+
+
+
+
+
Dues Reminders
+
Receive reminders when membership dues are due
+
+
+
+
+
+
+
Payment Confirmations
+
Receive confirmation when payments are recorded
+
+
+
+
+
+
+
Membership Updates
+
Important updates about your membership status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
News & Updates
+
Stay informed about Monaco USA activities
+
+
+
+
+
+
+
Announcements
+
Important announcements from Monaco USA
+
+
+
+
+
+
+
Newsletter
+
Receive the Monaco USA newsletter
+
+
+
+
+
+
+
+
+ {isSubmitting ? 'Saving...' : 'Save Notification Preferences'}
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'account'}
+
+
+ {#if data.hasMonacoEmailAccount && data.monacoEmail}
+
+
+
+
+
+
+
Monaco USA Email
+
Manage your @monacousa.org email account
+
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
New Email Password
+
+
+ (showPassword = !showPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Confirm New Password
+
+
+ (showConfirmPassword = !showConfirmPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showConfirmPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+ Password must be at least 8 characters long.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Update Email Password'}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
Email Address
+
Update your email address
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Current Email
+
+
+
+
+
New Email Address
+
+
+ A verification link will be sent to your new email address.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Update Email'}
+
+
+
+
+
+
+
+
+
+
+
+
Password
+
Change your account password
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
Current Password
+
+
+ (showCurrentPassword = !showCurrentPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showCurrentPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
New Password
+
+
+ (showPassword = !showPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Confirm New Password
+
+
+ (showConfirmPassword = !showConfirmPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showConfirmPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+ Password must be at least 8 characters long.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Change Password'}
+
+
+
+
+
+
+
Membership Information
+
+
+
Member ID
+
{member?.member_id || 'N/A'}
+
+
+
Membership Type
+
{member?.membership_type?.display_name || 'N/A'}
+
+
+
Member Since
+
+ {member?.member_since
+ ? new Date(member.member_since).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
Status
+
{member?.membership_status?.display_name || 'N/A'}
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte
new file mode 100644
index 0000000..4a673fa
--- /dev/null
+++ b/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
diff --git a/src/routes/(auth)/forgot-password/+page.server.ts b/src/routes/(auth)/forgot-password/+page.server.ts
new file mode 100644
index 0000000..22734f7
--- /dev/null
+++ b/src/routes/(auth)/forgot-password/+page.server.ts
@@ -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.'
+ };
+ }
+};
diff --git a/src/routes/(auth)/forgot-password/+page.svelte b/src/routes/(auth)/forgot-password/+page.svelte
new file mode 100644
index 0000000..d242b31
--- /dev/null
+++ b/src/routes/(auth)/forgot-password/+page.svelte
@@ -0,0 +1,83 @@
+
+
+
+ Forgot Password | Monaco USA
+
+
+
+
+
Forgot your password?
+
+ Enter your email and we'll send you a reset link
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ {#if loading}
+
+ Sending reset link...
+ {:else}
+ Send reset link
+ {/if}
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts
new file mode 100644
index 0000000..b2cd18c
--- /dev/null
+++ b/src/routes/(auth)/login/+page.server.ts
@@ -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);
+ }
+};
diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte
new file mode 100644
index 0000000..0929262
--- /dev/null
+++ b/src/routes/(auth)/login/+page.svelte
@@ -0,0 +1,110 @@
+
+
+
+ Sign In | Monaco USA
+
+
+
+
+
Welcome back
+
Sign in to your member account
+
+
+ {#if data.urlError}
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
+
+
+ {#if loading}
+
+ Signing in...
+ {:else}
+ Sign in
+ {/if}
+
+
+
+
+
+ Don't have an account?
+
+ Sign up
+
+
+
+
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts
new file mode 100644
index 0000000..1b5cf88
--- /dev/null
+++ b/src/routes/(auth)/signup/+page.server.ts
@@ -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 = {};
+
+ 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.'
+ };
+ }
+};
diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte
new file mode 100644
index 0000000..fec2221
--- /dev/null
+++ b/src/routes/(auth)/signup/+page.svelte
@@ -0,0 +1,256 @@
+
+
+
+ Sign Up | Monaco USA
+
+
+
+
+
Create your account
+
Join the Monaco USA community
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
+
+ Email address *
+
+
+
+
+
+
+
+ Phone number *
+
+
+
+
+
+
+
+ Date of birth *
+
+
+
You must be at least 18 years old to join.
+
+
+
+
+
+ Address *
+
+
+
+
+
+
+
+ Nationality *
+
+
+ {#if selectedNationalities.length === 0}
+
Select at least one nationality.
+ {/if}
+
+
+
+
+
+ Password *
+
+
+
At least 8 characters.
+
+
+
+
+
+ Confirm password *
+
+
+
+
+
+
+
+
+ {#if loading}
+
+ Creating account...
+ {:else}
+ Create account
+ {/if}
+
+
+ {/if}
+
+
+
+ Already have an account?
+ Sign in
+
+
+
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..a42a112
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,143 @@
+
+
+
+ {status} - {errorInfo.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{status}
+
+
+
{errorInfo.title}
+
+
+
{errorInfo.message}
+
+
+
+
+
+
+
+ Go Home
+
+
+
history.back()}>
+
+
+
+ Go Back
+
+
+
+
+
+
+ Need help?
+
+ Contact support
+
+
+
+
+
© 2026 Monaco USA. All rights reserved.
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..a424b90
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ Monaco USA Portal
+
+
+
+
+
+
+
+ {@render children()}
+
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
new file mode 100644
index 0000000..915bea8
--- /dev/null
+++ b/src/routes/+page.server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..50beda5
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+ Member Portal 2026
+
+
+
+ Monaco USA
+
+
+
+ Americans in Monaco - Your gateway to community events, member resources, and association
+ management.
+
+
+
+
+ Sign In
+
+
+ Learn More
+
+
+
+
+
+
+
+
+
+ Member Directory
+
+ Connect with fellow Americans in Monaco through our comprehensive member directory.
+
+
+
+
+
+
+
+ Events Calendar
+
+ Stay updated with social gatherings, meetings, and special events in the community.
+
+
+
+
+
+
+
+ Documents & Resources
+
+ Access meeting minutes, bylaws, and important association documents anytime.
+
+
+
+
+
+
+
+ Dues Management
+
+ Track your membership dues, view payment history, and manage your subscription.
+
+
+
+
+
+
+
+ Notifications
+
+ Receive timely reminders about events, dues, and important announcements.
+
+
+
+
+
+
+
+ Secure Access
+
+ Role-based access ensures members, board, and admins see exactly what they need.
+
+
+
+
+
+
+
+
+
+
150+
+
Active Members
+
+
+
50+
+
Events Per Year
+
+
+
+
100%
+
Community Driven
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+ Americans in Monaco since 1999
+
+
+
diff --git a/src/routes/api/auth/check-verification/+server.ts b/src/routes/api/auth/check-verification/+server.ts
new file mode 100644
index 0000000..b781e4e
--- /dev/null
+++ b/src/routes/api/auth/check-verification/+server.ts
@@ -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 });
+};
diff --git a/src/routes/api/auth/resend-verification/+server.ts b/src/routes/api/auth/resend-verification/+server.ts
new file mode 100644
index 0000000..36b1de7
--- /dev/null
+++ b/src/routes/api/auth/resend-verification/+server.ts
@@ -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' });
+};
diff --git a/src/routes/api/calendar/events/[id]/+server.ts b/src/routes/api/calendar/events/[id]/+server.ts
new file mode 100644
index 0000000..c4c8c5f
--- /dev/null
+++ b/src/routes/api/calendar/events/[id]/+server.ts
@@ -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;
+ }
+}
diff --git a/src/routes/api/calendar/feed/+server.ts b/src/routes/api/calendar/feed/+server.ts
new file mode 100644
index 0000000..0fa16bf
--- /dev/null
+++ b/src/routes/api/calendar/feed/+server.ts
@@ -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'];
+ }
+}
diff --git a/src/routes/api/calendar/public/events/[id]/+server.ts b/src/routes/api/calendar/public/events/[id]/+server.ts
new file mode 100644
index 0000000..689b53b
--- /dev/null
+++ b/src/routes/api/calendar/public/events/[id]/+server.ts
@@ -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
+ }
+ });
+};
diff --git a/src/routes/api/cron/dues-reminders/+server.ts b/src/routes/api/cron/dues-reminders/+server.ts
new file mode 100644
index 0000000..1a7902d
--- /dev/null
+++ b/src/routes/api/cron/dues-reminders/+server.ts
@@ -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,
+ 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,
+ pendingOnboarding: {
+ onboarding_reminder_7: 0,
+ onboarding_reminder_1: 0,
+ onboarding_expired: 0
+ } as Record
+ };
+
+ // 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 });
+ }
+};
diff --git a/src/routes/api/cron/event-reminders/+server.ts b/src/routes/api/cron/event-reminders/+server.ts
new file mode 100644
index 0000000..cfbd32f
--- /dev/null
+++ b/src/routes/api/cron/event-reminders/+server.ts
@@ -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 });
+ }
+};
diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts
new file mode 100644
index 0000000..ca00b0d
--- /dev/null
+++ b/src/routes/auth/callback/+server.ts
@@ -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);
+};
diff --git a/src/routes/auth/reset-password/+page.server.ts b/src/routes/auth/reset-password/+page.server.ts
new file mode 100644
index 0000000..9c90f85
--- /dev/null
+++ b/src/routes/auth/reset-password/+page.server.ts
@@ -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.'
+ };
+ }
+};
diff --git a/src/routes/auth/reset-password/+page.svelte b/src/routes/auth/reset-password/+page.svelte
new file mode 100644
index 0000000..0c42dd5
--- /dev/null
+++ b/src/routes/auth/reset-password/+page.svelte
@@ -0,0 +1,125 @@
+
+
+
+ {isInvite ? 'Set Your Password' : 'Reset Password'} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isInvite ? 'Welcome to Monaco USA!' : 'Reset your password'}
+
+
+ {#if isInvite}
+ Set a password to activate your account
+ {:else}
+ Enter a new password for your account
+ {/if}
+
+ {#if email}
+
{email}
+ {/if}
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+ Password must be at least 8 characters long.
+
+
+ {#if loading}
+
+ Resetting password...
+ {:else}
+ Reset password
+ {/if}
+
+
+ {/if}
+
+
+
+
diff --git a/src/routes/auth/verify/+server.ts b/src/routes/auth/verify/+server.ts
new file mode 100644
index 0000000..40200cd
--- /dev/null
+++ b/src/routes/auth/verify/+server.ts
@@ -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')}`);
+};
diff --git a/src/routes/join/+layout.svelte b/src/routes/join/+layout.svelte
new file mode 100644
index 0000000..911074a
--- /dev/null
+++ b/src/routes/join/+layout.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
diff --git a/src/routes/join/+page.server.ts b/src/routes/join/+page.server.ts
new file mode 100644
index 0000000..992871c
--- /dev/null
+++ b/src/routes/join/+page.server.ts
@@ -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 = {};
+ 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 = {};
+ 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
+ };
+ }
+};
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte
new file mode 100644
index 0000000..2faae76
--- /dev/null
+++ b/src/routes/join/+page.svelte
@@ -0,0 +1,773 @@
+
+
+
+ Join Monaco USA
+
+
+
+
+
+
+ {#each steps as step}
+
+
+ {#if currentStep > step.num}
+
+ {:else}
+ {step.num}
+ {/if}
+
+ {#if step.num < 6}
+
+ {/if}
+
+ {/each}
+
+
+ Step {currentStep} of 6: {steps[currentStep - 1].title}
+
+
+
+
+
+ {#key currentStep}
+
+
+ {#if currentStep === 1}
+
+
+
Welcome to Monaco USA
+
Join our vibrant community of Americans in Monaco
+
+
+
+ {#each benefits as benefit, i}
+
+
+
+
+
{benefit.title}
+
{benefit.description}
+
+ {/each}
+
+
+
+
+
+ Already have an account?
+ Sign in
+
+
+ {/if}
+
+
+ {#if currentStep === 2}
+
+
+
+
+
+
Your Information
+
Tell us about yourself to create your account
+
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-4 space-y-3"
+ >
+
+
+
+
+
+
+ Email *
+
+
+
+
+
+
+
+
+
+
+ Nationality *
+
+
+
+
+
+
+
+ Date of birth *
+
+
+
You must be at least 18 years old.
+
+
+
+
+
+ Street address *
+
+
+
+
+
+
+
+
+ City *
+
+
+
+
+
+ Country of residence *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {#if loading}
+
+ Creating...
+ {:else}
+ Continue
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 3}
+
+
+
Add a Profile Photo
+
Help other members recognize you (optional)
+
+
+
+
+ {#if avatarPreview}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ JPEG, PNG, or WebP. Max 5MB.
+
+
+
{
+ avatarUploading = true;
+ return async ({ update }) => {
+ avatarUploading = false;
+ await update();
+ };
+ }}
+ class="mt-6 w-full"
+ >
+ {#if avatarFile}
+
+ {/if}
+
+
+
+
+ Back
+
+
+ Skip
+
+
+ {#if avatarFile}
+
+ {#if avatarUploading}
+
+ Uploading...
+ {:else}
+ Continue
+
+ {/if}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 4}
+
+
+
Explore Your New Home
+
Here's what you'll have access to as a member
+
+
+
+ {#each features as feature, i}
+
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+ {/each}
+
+
+
+
+
+ Back
+
+
+ Continue
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 5}
+
+
+
+
+
+
Verify Your Email
+
+ We sent a verification link to
+ {memberEmail || email}
+
+
+
+
+
+ Click the link in your email to verify your address, then click the button below.
+
+
+
+
+
+ {#if verificationChecking}
+
+ Checking...
+ {:else}
+ I've Verified My Email
+
+ {/if}
+
+
+
+ {#if resendingEmail}
+
+ Sending...
+ {:else}
+
+ Resend Verification Email
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 6}
+
+
+
+
+
+
You're Almost There!
+
+ Complete your membership by paying your annual dues within 30 days
+
+
+
+
+
+
Annual Membership
+
€{data?.duesAmount || '150'}
+
+
+
+
+
Bank Transfer Details
+
+
+ Account Holder:
+ {data?.paymentSettings?.account_holder || 'Monaco USA'}
+
+
+ Bank:
+ {data?.paymentSettings?.bank_name || 'Credit Foncier de Monaco'}
+
+
+ IBAN:
+ {data?.paymentSettings?.iban || 'MC58...'}
+
+
+ Reference:
+ {memberId || 'MUSA-2026-XXXX'}
+
+
+
+
+
+
What Happens Next?
+
+ • Check your email for confirmation
+ • Make your bank transfer within 30 days
+ • We'll activate your account once payment is received
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-4"
+ >
+
+ {#if loading}
+
+ Completing...
+ {:else}
+ Go to Dashboard
+
+ {/if}
+
+
+
+ {/if}
+
+ {/key}
+
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts
new file mode 100644
index 0000000..f0e03b2
--- /dev/null
+++ b/src/routes/logout/+server.ts
@@ -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');
+};
diff --git a/src/routes/public/events/[id]/+page.server.ts b/src/routes/public/events/[id]/+page.server.ts
new file mode 100644
index 0000000..79b8aab
--- /dev/null
+++ b/src/routes/public/events/[id]/+page.server.ts
@@ -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.'
+ };
+ }
+};
diff --git a/src/routes/public/events/[id]/+page.svelte b/src/routes/public/events/[id]/+page.svelte
new file mode 100644
index 0000000..891ef93
--- /dev/null
+++ b/src/routes/public/events/[id]/+page.svelte
@@ -0,0 +1,369 @@
+
+
+
+ {event.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+ {#if event.event_type_name}
+
+ {event.event_type_name}
+
+ {/if}
+
+
{event.title}
+
+
+
+
+ {formatDate(eventDate)}
+
+ {#if !event.all_day}
+
+
+ {formatTime(eventDate)} - {formatTime(eventEndDate)}
+
+ {/if}
+ {#if event.location}
+
+
+ {event.location}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if event.description}
+
+
About This Event
+
+ {@html event.description.replace(/\n/g, ' ')}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
Date & Time
+
+ {formatDate(eventDate)}
+ {#if !event.all_day}
+ {formatTime(eventDate)} - {formatTime(eventEndDate)} ({event.timezone})
+ {:else}
+ All day event
+ {/if}
+
+
+
+
+ {#if event.location}
+
+
+
+
+
+
Location
+
{event.location}
+ {#if event.location_url}
+
+ View on map
+
+ {/if}
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+
+
+
+
Price
+
+ Non-members: {formatCurrency(event.non_member_price)}
+ {#if event.pricing_notes}
+ {event.pricing_notes}
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
+
+
Attendance
+
+ {event.total_attendees} registered
+ {#if event.max_attendees}
+ / {event.max_attendees} capacity
+ {/if}
+
+ {#if event.waitlist_count > 0}
+
{event.waitlist_count} on waitlist
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if form?.success}
+
+
+
You're Registered!
+
{form.success}
+
+ {:else}
+
Register for This Event
+
+ {#if spotsRemaining !== null && spotsRemaining <= 5}
+
+
+
+ {#if spotsRemaining === 0}
+ Event is full - Join waitlist
+ {:else}
+ Only {spotsRemaining} spot{spotsRemaining === 1 ? '' : 's'} left!
+ {/if}
+
+
+ {/if}
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-6 space-y-4"
+ >
+
+ Full Name *
+
+
+
+
+ Email *
+
+
+
+
+ Phone (optional)
+
+
+
+ {#if event.max_guests_per_member !== 0}
+
+ Additional Guests
+
+ {#each Array(Math.min((event.max_guests_per_member || 5) + 1, 6)) as _, i}
+ {i} guest{i !== 1 ? 's' : ''}
+ {/each}
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+ Total
+
+ {formatCurrency(event.non_member_price * (1 + guestCount))}
+
+
+
Payment details will be provided after registration
+
+ {/if}
+
+
+ {#if loading}
+
+ Registering...
+ {:else if spotsRemaining === 0}
+ Join Waitlist
+ {:else}
+ Register Now
+ {/if}
+
+
+
+ Already a Monaco USA member?
+ Sign in
+ to RSVP with your account.
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/static/MONACOUSA-Flags_376x376.png b/static/MONACOUSA-Flags_376x376.png
new file mode 100644
index 0000000..24314cf
Binary files /dev/null and b/static/MONACOUSA-Flags_376x376.png differ
diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png
new file mode 100644
index 0000000..951e456
Binary files /dev/null and b/static/apple-touch-icon.png differ
diff --git a/static/favicon-32x32.png b/static/favicon-32x32.png
new file mode 100644
index 0000000..b60117a
Binary files /dev/null and b/static/favicon-32x32.png differ
diff --git a/static/flags/ad.svg b/static/flags/ad.svg
new file mode 100644
index 0000000..199ff19
--- /dev/null
+++ b/static/flags/ad.svg
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ae.svg b/static/flags/ae.svg
new file mode 100644
index 0000000..651ac85
--- /dev/null
+++ b/static/flags/ae.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/af.svg b/static/flags/af.svg
new file mode 100644
index 0000000..4dbe455
--- /dev/null
+++ b/static/flags/af.svg
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ag.svg b/static/flags/ag.svg
new file mode 100644
index 0000000..243c3d8
--- /dev/null
+++ b/static/flags/ag.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ai.svg b/static/flags/ai.svg
new file mode 100644
index 0000000..9c2ea33
--- /dev/null
+++ b/static/flags/ai.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/al.svg b/static/flags/al.svg
new file mode 100644
index 0000000..e85d95f
--- /dev/null
+++ b/static/flags/al.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/am.svg b/static/flags/am.svg
new file mode 100644
index 0000000..99fa4dc
--- /dev/null
+++ b/static/flags/am.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ao.svg b/static/flags/ao.svg
new file mode 100644
index 0000000..b73b1ec
--- /dev/null
+++ b/static/flags/ao.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/aq.svg b/static/flags/aq.svg
new file mode 100644
index 0000000..c7e3536
--- /dev/null
+++ b/static/flags/aq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ar.svg b/static/flags/ar.svg
new file mode 100644
index 0000000..c753da1
--- /dev/null
+++ b/static/flags/ar.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/arab.svg b/static/flags/arab.svg
new file mode 100644
index 0000000..9ef079f
--- /dev/null
+++ b/static/flags/arab.svg
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/as.svg b/static/flags/as.svg
new file mode 100644
index 0000000..82459de
--- /dev/null
+++ b/static/flags/as.svg
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/asean.svg b/static/flags/asean.svg
new file mode 100644
index 0000000..189ae02
--- /dev/null
+++ b/static/flags/asean.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/at.svg b/static/flags/at.svg
new file mode 100644
index 0000000..9d2775c
--- /dev/null
+++ b/static/flags/at.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/au.svg b/static/flags/au.svg
new file mode 100644
index 0000000..96e8076
--- /dev/null
+++ b/static/flags/au.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/aw.svg b/static/flags/aw.svg
new file mode 100644
index 0000000..413b7c4
--- /dev/null
+++ b/static/flags/aw.svg
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ax.svg b/static/flags/ax.svg
new file mode 100644
index 0000000..0584d71
--- /dev/null
+++ b/static/flags/ax.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/az.svg b/static/flags/az.svg
new file mode 100644
index 0000000..3557522
--- /dev/null
+++ b/static/flags/az.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/ba.svg b/static/flags/ba.svg
new file mode 100644
index 0000000..93bd9cf
--- /dev/null
+++ b/static/flags/ba.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bb.svg b/static/flags/bb.svg
new file mode 100644
index 0000000..cecd5cc
--- /dev/null
+++ b/static/flags/bb.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/bd.svg b/static/flags/bd.svg
new file mode 100644
index 0000000..16b794d
--- /dev/null
+++ b/static/flags/bd.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/be.svg b/static/flags/be.svg
new file mode 100644
index 0000000..ac706a0
--- /dev/null
+++ b/static/flags/be.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/bf.svg b/static/flags/bf.svg
new file mode 100644
index 0000000..4713822
--- /dev/null
+++ b/static/flags/bf.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/bg.svg b/static/flags/bg.svg
new file mode 100644
index 0000000..af2d0d0
--- /dev/null
+++ b/static/flags/bg.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/bh.svg b/static/flags/bh.svg
new file mode 100644
index 0000000..7a2ea54
--- /dev/null
+++ b/static/flags/bh.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/bi.svg b/static/flags/bi.svg
new file mode 100644
index 0000000..a4434a9
--- /dev/null
+++ b/static/flags/bi.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bj.svg b/static/flags/bj.svg
new file mode 100644
index 0000000..0846724
--- /dev/null
+++ b/static/flags/bj.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bl.svg b/static/flags/bl.svg
new file mode 100644
index 0000000..f84cbba
--- /dev/null
+++ b/static/flags/bl.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/bm.svg b/static/flags/bm.svg
new file mode 100644
index 0000000..f43a5eb
--- /dev/null
+++ b/static/flags/bm.svg
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bn.svg b/static/flags/bn.svg
new file mode 100644
index 0000000..f544c25
--- /dev/null
+++ b/static/flags/bn.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bo.svg b/static/flags/bo.svg
new file mode 100644
index 0000000..7658e3f
--- /dev/null
+++ b/static/flags/bo.svg
@@ -0,0 +1,673 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bq.svg b/static/flags/bq.svg
new file mode 100644
index 0000000..0e6bc76
--- /dev/null
+++ b/static/flags/bq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/br.svg b/static/flags/br.svg
new file mode 100644
index 0000000..719a763
--- /dev/null
+++ b/static/flags/br.svg
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bs.svg b/static/flags/bs.svg
new file mode 100644
index 0000000..5cc918e
--- /dev/null
+++ b/static/flags/bs.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bt.svg b/static/flags/bt.svg
new file mode 100644
index 0000000..20aef3a
--- /dev/null
+++ b/static/flags/bt.svg
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bv.svg b/static/flags/bv.svg
new file mode 100644
index 0000000..40e16d9
--- /dev/null
+++ b/static/flags/bv.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bw.svg b/static/flags/bw.svg
new file mode 100644
index 0000000..3435608
--- /dev/null
+++ b/static/flags/bw.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/by.svg b/static/flags/by.svg
new file mode 100644
index 0000000..948784f
--- /dev/null
+++ b/static/flags/by.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/bz.svg b/static/flags/bz.svg
new file mode 100644
index 0000000..d81b16c
--- /dev/null
+++ b/static/flags/bz.svg
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ca.svg b/static/flags/ca.svg
new file mode 100644
index 0000000..c9b23b4
--- /dev/null
+++ b/static/flags/ca.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/cc.svg b/static/flags/cc.svg
new file mode 100644
index 0000000..a42dec6
--- /dev/null
+++ b/static/flags/cc.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cd.svg b/static/flags/cd.svg
new file mode 100644
index 0000000..b9cf528
--- /dev/null
+++ b/static/flags/cd.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/cefta.svg b/static/flags/cefta.svg
new file mode 100644
index 0000000..f748d08
--- /dev/null
+++ b/static/flags/cefta.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cf.svg b/static/flags/cf.svg
new file mode 100644
index 0000000..a6cd367
--- /dev/null
+++ b/static/flags/cf.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cg.svg b/static/flags/cg.svg
new file mode 100644
index 0000000..f5a0e42
--- /dev/null
+++ b/static/flags/cg.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ch.svg b/static/flags/ch.svg
new file mode 100644
index 0000000..b42d670
--- /dev/null
+++ b/static/flags/ch.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ci.svg b/static/flags/ci.svg
new file mode 100644
index 0000000..e400f0c
--- /dev/null
+++ b/static/flags/ci.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/ck.svg b/static/flags/ck.svg
new file mode 100644
index 0000000..18e547b
--- /dev/null
+++ b/static/flags/ck.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cl.svg b/static/flags/cl.svg
new file mode 100644
index 0000000..5b3c72f
--- /dev/null
+++ b/static/flags/cl.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cm.svg b/static/flags/cm.svg
new file mode 100644
index 0000000..70adc8b
--- /dev/null
+++ b/static/flags/cm.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cn.svg b/static/flags/cn.svg
new file mode 100644
index 0000000..10d3489
--- /dev/null
+++ b/static/flags/cn.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/co.svg b/static/flags/co.svg
new file mode 100644
index 0000000..ebd0a0f
--- /dev/null
+++ b/static/flags/co.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/cp.svg b/static/flags/cp.svg
new file mode 100644
index 0000000..b8aa9cf
--- /dev/null
+++ b/static/flags/cp.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/cr.svg b/static/flags/cr.svg
new file mode 100644
index 0000000..5a409ee
--- /dev/null
+++ b/static/flags/cr.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/cu.svg b/static/flags/cu.svg
new file mode 100644
index 0000000..053c9ee
--- /dev/null
+++ b/static/flags/cu.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cv.svg b/static/flags/cv.svg
new file mode 100644
index 0000000..aec8994
--- /dev/null
+++ b/static/flags/cv.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cw.svg b/static/flags/cw.svg
new file mode 100644
index 0000000..bb0ece2
--- /dev/null
+++ b/static/flags/cw.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cx.svg b/static/flags/cx.svg
new file mode 100644
index 0000000..3a83c23
--- /dev/null
+++ b/static/flags/cx.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/cy.svg b/static/flags/cy.svg
new file mode 100644
index 0000000..ee4b0c7
--- /dev/null
+++ b/static/flags/cy.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/cz.svg b/static/flags/cz.svg
new file mode 100644
index 0000000..7913de3
--- /dev/null
+++ b/static/flags/cz.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/de.svg b/static/flags/de.svg
new file mode 100644
index 0000000..71aa2d2
--- /dev/null
+++ b/static/flags/de.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/dg.svg b/static/flags/dg.svg
new file mode 100644
index 0000000..dfee2bb
--- /dev/null
+++ b/static/flags/dg.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/dj.svg b/static/flags/dj.svg
new file mode 100644
index 0000000..9b00a82
--- /dev/null
+++ b/static/flags/dj.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/dk.svg b/static/flags/dk.svg
new file mode 100644
index 0000000..563277f
--- /dev/null
+++ b/static/flags/dk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/dm.svg b/static/flags/dm.svg
new file mode 100644
index 0000000..5aa9cea
--- /dev/null
+++ b/static/flags/dm.svg
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/do.svg b/static/flags/do.svg
new file mode 100644
index 0000000..6de2b26
--- /dev/null
+++ b/static/flags/do.svg
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/dz.svg b/static/flags/dz.svg
new file mode 100644
index 0000000..5ff29a7
--- /dev/null
+++ b/static/flags/dz.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/eac.svg b/static/flags/eac.svg
new file mode 100644
index 0000000..59d02d2
--- /dev/null
+++ b/static/flags/eac.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ec.svg b/static/flags/ec.svg
new file mode 100644
index 0000000..88c50bf
--- /dev/null
+++ b/static/flags/ec.svg
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ee.svg b/static/flags/ee.svg
new file mode 100644
index 0000000..8b98c2c
--- /dev/null
+++ b/static/flags/ee.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/eg.svg b/static/flags/eg.svg
new file mode 100644
index 0000000..88e32b3
--- /dev/null
+++ b/static/flags/eg.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/eh.svg b/static/flags/eh.svg
new file mode 100644
index 0000000..6aec728
--- /dev/null
+++ b/static/flags/eh.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/er.svg b/static/flags/er.svg
new file mode 100644
index 0000000..48a13b4
--- /dev/null
+++ b/static/flags/er.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/es-ct.svg b/static/flags/es-ct.svg
new file mode 100644
index 0000000..4d85911
--- /dev/null
+++ b/static/flags/es-ct.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/es-ga.svg b/static/flags/es-ga.svg
new file mode 100644
index 0000000..573ca45
--- /dev/null
+++ b/static/flags/es-ga.svg
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/es-pv.svg b/static/flags/es-pv.svg
new file mode 100644
index 0000000..63c19f4
--- /dev/null
+++ b/static/flags/es-pv.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/es.svg b/static/flags/es.svg
new file mode 100644
index 0000000..a296ebf
--- /dev/null
+++ b/static/flags/es.svg
@@ -0,0 +1,544 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/et.svg b/static/flags/et.svg
new file mode 100644
index 0000000..3f99be4
--- /dev/null
+++ b/static/flags/et.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/eu.svg b/static/flags/eu.svg
new file mode 100644
index 0000000..b0874c1
--- /dev/null
+++ b/static/flags/eu.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/fi.svg b/static/flags/fi.svg
new file mode 100644
index 0000000..470be2d
--- /dev/null
+++ b/static/flags/fi.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/fj.svg b/static/flags/fj.svg
new file mode 100644
index 0000000..332ae61
--- /dev/null
+++ b/static/flags/fj.svg
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/fk.svg b/static/flags/fk.svg
new file mode 100644
index 0000000..a0dace8
--- /dev/null
+++ b/static/flags/fk.svg
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/fm.svg b/static/flags/fm.svg
new file mode 100644
index 0000000..c1b7c97
--- /dev/null
+++ b/static/flags/fm.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/fo.svg b/static/flags/fo.svg
new file mode 100644
index 0000000..f802d28
--- /dev/null
+++ b/static/flags/fo.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/fr.svg b/static/flags/fr.svg
new file mode 100644
index 0000000..4110e59
--- /dev/null
+++ b/static/flags/fr.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ga.svg b/static/flags/ga.svg
new file mode 100644
index 0000000..76edab4
--- /dev/null
+++ b/static/flags/ga.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/gb-eng.svg b/static/flags/gb-eng.svg
new file mode 100644
index 0000000..12e3b67
--- /dev/null
+++ b/static/flags/gb-eng.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/gb-nir.svg b/static/flags/gb-nir.svg
new file mode 100644
index 0000000..e22190a
--- /dev/null
+++ b/static/flags/gb-nir.svg
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gb-sct.svg b/static/flags/gb-sct.svg
new file mode 100644
index 0000000..f50cd32
--- /dev/null
+++ b/static/flags/gb-sct.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/gb-wls.svg b/static/flags/gb-wls.svg
new file mode 100644
index 0000000..d7f5791
--- /dev/null
+++ b/static/flags/gb-wls.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gb.svg b/static/flags/gb.svg
new file mode 100644
index 0000000..7991383
--- /dev/null
+++ b/static/flags/gb.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/gd.svg b/static/flags/gd.svg
new file mode 100644
index 0000000..b3d250d
--- /dev/null
+++ b/static/flags/gd.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ge.svg b/static/flags/ge.svg
new file mode 100644
index 0000000..ab08a9a
--- /dev/null
+++ b/static/flags/ge.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/gf.svg b/static/flags/gf.svg
new file mode 100644
index 0000000..f8fe94c
--- /dev/null
+++ b/static/flags/gf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/gg.svg b/static/flags/gg.svg
new file mode 100644
index 0000000..f8216c8
--- /dev/null
+++ b/static/flags/gg.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gh.svg b/static/flags/gh.svg
new file mode 100644
index 0000000..5c3e3e6
--- /dev/null
+++ b/static/flags/gh.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/gi.svg b/static/flags/gi.svg
new file mode 100644
index 0000000..a5d7570
--- /dev/null
+++ b/static/flags/gi.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gl.svg b/static/flags/gl.svg
new file mode 100644
index 0000000..eb5a52e
--- /dev/null
+++ b/static/flags/gl.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/gm.svg b/static/flags/gm.svg
new file mode 100644
index 0000000..8fe9d66
--- /dev/null
+++ b/static/flags/gm.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gn.svg b/static/flags/gn.svg
new file mode 100644
index 0000000..40d6ad4
--- /dev/null
+++ b/static/flags/gn.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/gp.svg b/static/flags/gp.svg
new file mode 100644
index 0000000..ee55c4b
--- /dev/null
+++ b/static/flags/gp.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/gq.svg b/static/flags/gq.svg
new file mode 100644
index 0000000..64c8eb2
--- /dev/null
+++ b/static/flags/gq.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gr.svg b/static/flags/gr.svg
new file mode 100644
index 0000000..599741e
--- /dev/null
+++ b/static/flags/gr.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gs.svg b/static/flags/gs.svg
new file mode 100644
index 0000000..29db9b9
--- /dev/null
+++ b/static/flags/gs.svg
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gt.svg b/static/flags/gt.svg
new file mode 100644
index 0000000..7df9df5
--- /dev/null
+++ b/static/flags/gt.svg
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gu.svg b/static/flags/gu.svg
new file mode 100644
index 0000000..3b95219
--- /dev/null
+++ b/static/flags/gu.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gw.svg b/static/flags/gw.svg
new file mode 100644
index 0000000..d470bac
--- /dev/null
+++ b/static/flags/gw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/gy.svg b/static/flags/gy.svg
new file mode 100644
index 0000000..569fb56
--- /dev/null
+++ b/static/flags/gy.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/hk.svg b/static/flags/hk.svg
new file mode 100644
index 0000000..4fd55bc
--- /dev/null
+++ b/static/flags/hk.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/hm.svg b/static/flags/hm.svg
new file mode 100644
index 0000000..815c482
--- /dev/null
+++ b/static/flags/hm.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/hn.svg b/static/flags/hn.svg
new file mode 100644
index 0000000..11fde67
--- /dev/null
+++ b/static/flags/hn.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/hr.svg b/static/flags/hr.svg
new file mode 100644
index 0000000..dde825c
--- /dev/null
+++ b/static/flags/hr.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ht.svg b/static/flags/ht.svg
new file mode 100644
index 0000000..8e8efc4
--- /dev/null
+++ b/static/flags/ht.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/hu.svg b/static/flags/hu.svg
new file mode 100644
index 0000000..baddf7f
--- /dev/null
+++ b/static/flags/hu.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/ic.svg b/static/flags/ic.svg
new file mode 100644
index 0000000..81e6ee2
--- /dev/null
+++ b/static/flags/ic.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/id.svg b/static/flags/id.svg
new file mode 100644
index 0000000..3b7c8fc
--- /dev/null
+++ b/static/flags/id.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/ie.svg b/static/flags/ie.svg
new file mode 100644
index 0000000..049be14
--- /dev/null
+++ b/static/flags/ie.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/il.svg b/static/flags/il.svg
new file mode 100644
index 0000000..f43be7e
--- /dev/null
+++ b/static/flags/il.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/im.svg b/static/flags/im.svg
new file mode 100644
index 0000000..fe6a59a
--- /dev/null
+++ b/static/flags/im.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/in.svg b/static/flags/in.svg
new file mode 100644
index 0000000..bc47d74
--- /dev/null
+++ b/static/flags/in.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/io.svg b/static/flags/io.svg
new file mode 100644
index 0000000..3058f7d
--- /dev/null
+++ b/static/flags/io.svg
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/iq.svg b/static/flags/iq.svg
new file mode 100644
index 0000000..8044514
--- /dev/null
+++ b/static/flags/iq.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ir.svg b/static/flags/ir.svg
new file mode 100644
index 0000000..8c6d516
--- /dev/null
+++ b/static/flags/ir.svg
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/is.svg b/static/flags/is.svg
new file mode 100644
index 0000000..a6588af
--- /dev/null
+++ b/static/flags/is.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/it.svg b/static/flags/it.svg
new file mode 100644
index 0000000..20a8bfd
--- /dev/null
+++ b/static/flags/it.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/je.svg b/static/flags/je.svg
new file mode 100644
index 0000000..70a8754
--- /dev/null
+++ b/static/flags/je.svg
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/jm.svg b/static/flags/jm.svg
new file mode 100644
index 0000000..269df03
--- /dev/null
+++ b/static/flags/jm.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/jo.svg b/static/flags/jo.svg
new file mode 100644
index 0000000..d6f927d
--- /dev/null
+++ b/static/flags/jo.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/jp.svg b/static/flags/jp.svg
new file mode 100644
index 0000000..cc1c181
--- /dev/null
+++ b/static/flags/jp.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ke.svg b/static/flags/ke.svg
new file mode 100644
index 0000000..3a67ca3
--- /dev/null
+++ b/static/flags/ke.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kg.svg b/static/flags/kg.svg
new file mode 100644
index 0000000..e26db95
--- /dev/null
+++ b/static/flags/kg.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/kh.svg b/static/flags/kh.svg
new file mode 100644
index 0000000..a7d52f2
--- /dev/null
+++ b/static/flags/kh.svg
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ki.svg b/static/flags/ki.svg
new file mode 100644
index 0000000..fda03f3
--- /dev/null
+++ b/static/flags/ki.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/km.svg b/static/flags/km.svg
new file mode 100644
index 0000000..414d65e
--- /dev/null
+++ b/static/flags/km.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kn.svg b/static/flags/kn.svg
new file mode 100644
index 0000000..47fe64d
--- /dev/null
+++ b/static/flags/kn.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kp.svg b/static/flags/kp.svg
new file mode 100644
index 0000000..ad1b713
--- /dev/null
+++ b/static/flags/kp.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kr.svg b/static/flags/kr.svg
new file mode 100644
index 0000000..6947eab
--- /dev/null
+++ b/static/flags/kr.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kw.svg b/static/flags/kw.svg
new file mode 100644
index 0000000..3dd89e9
--- /dev/null
+++ b/static/flags/kw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ky.svg b/static/flags/ky.svg
new file mode 100644
index 0000000..aeaa7e0
--- /dev/null
+++ b/static/flags/ky.svg
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/kz.svg b/static/flags/kz.svg
new file mode 100644
index 0000000..2fac45b
--- /dev/null
+++ b/static/flags/kz.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/la.svg b/static/flags/la.svg
new file mode 100644
index 0000000..6aea6b7
--- /dev/null
+++ b/static/flags/la.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/lb.svg b/static/flags/lb.svg
new file mode 100644
index 0000000..bde2581
--- /dev/null
+++ b/static/flags/lb.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/lc.svg b/static/flags/lc.svg
new file mode 100644
index 0000000..bb25654
--- /dev/null
+++ b/static/flags/lc.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/li.svg b/static/flags/li.svg
new file mode 100644
index 0000000..7a4d183
--- /dev/null
+++ b/static/flags/li.svg
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/lk.svg b/static/flags/lk.svg
new file mode 100644
index 0000000..cbd660a
--- /dev/null
+++ b/static/flags/lk.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/lr.svg b/static/flags/lr.svg
new file mode 100644
index 0000000..e482ab9
--- /dev/null
+++ b/static/flags/lr.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ls.svg b/static/flags/ls.svg
new file mode 100644
index 0000000..a7c01a9
--- /dev/null
+++ b/static/flags/ls.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/lt.svg b/static/flags/lt.svg
new file mode 100644
index 0000000..90ec5d2
--- /dev/null
+++ b/static/flags/lt.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/lu.svg b/static/flags/lu.svg
new file mode 100644
index 0000000..cc12206
--- /dev/null
+++ b/static/flags/lu.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/lv.svg b/static/flags/lv.svg
new file mode 100644
index 0000000..6a9e75e
--- /dev/null
+++ b/static/flags/lv.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/ly.svg b/static/flags/ly.svg
new file mode 100644
index 0000000..1eaa51e
--- /dev/null
+++ b/static/flags/ly.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ma.svg b/static/flags/ma.svg
new file mode 100644
index 0000000..7ce56ef
--- /dev/null
+++ b/static/flags/ma.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/mc.svg b/static/flags/mc.svg
new file mode 100644
index 0000000..9cb6c9e
--- /dev/null
+++ b/static/flags/mc.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/md.svg b/static/flags/md.svg
new file mode 100644
index 0000000..e9ba506
--- /dev/null
+++ b/static/flags/md.svg
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/me.svg b/static/flags/me.svg
new file mode 100644
index 0000000..297888c
--- /dev/null
+++ b/static/flags/me.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mf.svg b/static/flags/mf.svg
new file mode 100644
index 0000000..6305edc
--- /dev/null
+++ b/static/flags/mf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/mg.svg b/static/flags/mg.svg
new file mode 100644
index 0000000..5fa2d24
--- /dev/null
+++ b/static/flags/mg.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/mh.svg b/static/flags/mh.svg
new file mode 100644
index 0000000..7b9f490
--- /dev/null
+++ b/static/flags/mh.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/mk.svg b/static/flags/mk.svg
new file mode 100644
index 0000000..4f5cae7
--- /dev/null
+++ b/static/flags/mk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ml.svg b/static/flags/ml.svg
new file mode 100644
index 0000000..6f6b716
--- /dev/null
+++ b/static/flags/ml.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/mm.svg b/static/flags/mm.svg
new file mode 100644
index 0000000..42b4dee
--- /dev/null
+++ b/static/flags/mm.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mn.svg b/static/flags/mn.svg
new file mode 100644
index 0000000..6a38a71
--- /dev/null
+++ b/static/flags/mn.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mo.svg b/static/flags/mo.svg
new file mode 100644
index 0000000..f638b6c
--- /dev/null
+++ b/static/flags/mo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mp.svg b/static/flags/mp.svg
new file mode 100644
index 0000000..26bfa22
--- /dev/null
+++ b/static/flags/mp.svg
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mq.svg b/static/flags/mq.svg
new file mode 100644
index 0000000..b221951
--- /dev/null
+++ b/static/flags/mq.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/mr.svg b/static/flags/mr.svg
new file mode 100644
index 0000000..d859972
--- /dev/null
+++ b/static/flags/mr.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/ms.svg b/static/flags/ms.svg
new file mode 100644
index 0000000..4367505
--- /dev/null
+++ b/static/flags/ms.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mt.svg b/static/flags/mt.svg
new file mode 100644
index 0000000..5d5d7c8
--- /dev/null
+++ b/static/flags/mt.svg
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mu.svg b/static/flags/mu.svg
new file mode 100644
index 0000000..82d7a3b
--- /dev/null
+++ b/static/flags/mu.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/mv.svg b/static/flags/mv.svg
new file mode 100644
index 0000000..10450f9
--- /dev/null
+++ b/static/flags/mv.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/mw.svg b/static/flags/mw.svg
new file mode 100644
index 0000000..137ff87
--- /dev/null
+++ b/static/flags/mw.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mx.svg b/static/flags/mx.svg
new file mode 100644
index 0000000..e3ec2bc
--- /dev/null
+++ b/static/flags/mx.svg
@@ -0,0 +1,382 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/my.svg b/static/flags/my.svg
new file mode 100644
index 0000000..115f864
--- /dev/null
+++ b/static/flags/my.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/mz.svg b/static/flags/mz.svg
new file mode 100644
index 0000000..0f94c3a
--- /dev/null
+++ b/static/flags/mz.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/na.svg b/static/flags/na.svg
new file mode 100644
index 0000000..35b9f78
--- /dev/null
+++ b/static/flags/na.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/nc.svg b/static/flags/nc.svg
new file mode 100644
index 0000000..fa15551
--- /dev/null
+++ b/static/flags/nc.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ne.svg b/static/flags/ne.svg
new file mode 100644
index 0000000..39a82b8
--- /dev/null
+++ b/static/flags/ne.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/nf.svg b/static/flags/nf.svg
new file mode 100644
index 0000000..fd61b25
--- /dev/null
+++ b/static/flags/nf.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ng.svg b/static/flags/ng.svg
new file mode 100644
index 0000000..81eb35f
--- /dev/null
+++ b/static/flags/ng.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/ni.svg b/static/flags/ni.svg
new file mode 100644
index 0000000..e4861f5
--- /dev/null
+++ b/static/flags/ni.svg
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/nl.svg b/static/flags/nl.svg
new file mode 100644
index 0000000..e90f5b0
--- /dev/null
+++ b/static/flags/nl.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/no.svg b/static/flags/no.svg
new file mode 100644
index 0000000..a5f2a15
--- /dev/null
+++ b/static/flags/no.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/np.svg b/static/flags/np.svg
new file mode 100644
index 0000000..6242856
--- /dev/null
+++ b/static/flags/np.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/nr.svg b/static/flags/nr.svg
new file mode 100644
index 0000000..ff394c4
--- /dev/null
+++ b/static/flags/nr.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/nu.svg b/static/flags/nu.svg
new file mode 100644
index 0000000..4067baf
--- /dev/null
+++ b/static/flags/nu.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/nz.svg b/static/flags/nz.svg
new file mode 100644
index 0000000..935d8a7
--- /dev/null
+++ b/static/flags/nz.svg
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/om.svg b/static/flags/om.svg
new file mode 100644
index 0000000..4f1461a
--- /dev/null
+++ b/static/flags/om.svg
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pa.svg b/static/flags/pa.svg
new file mode 100644
index 0000000..8dc03bc
--- /dev/null
+++ b/static/flags/pa.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pc.svg b/static/flags/pc.svg
new file mode 100644
index 0000000..5202d6d
--- /dev/null
+++ b/static/flags/pc.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pe.svg b/static/flags/pe.svg
new file mode 100644
index 0000000..33e6cfd
--- /dev/null
+++ b/static/flags/pe.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/pf.svg b/static/flags/pf.svg
new file mode 100644
index 0000000..bea0354
--- /dev/null
+++ b/static/flags/pf.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pg.svg b/static/flags/pg.svg
new file mode 100644
index 0000000..7b7e77a
--- /dev/null
+++ b/static/flags/pg.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ph.svg b/static/flags/ph.svg
new file mode 100644
index 0000000..b910e24
--- /dev/null
+++ b/static/flags/ph.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/pk.svg b/static/flags/pk.svg
new file mode 100644
index 0000000..4ddc19f
--- /dev/null
+++ b/static/flags/pk.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pl.svg b/static/flags/pl.svg
new file mode 100644
index 0000000..0fa5145
--- /dev/null
+++ b/static/flags/pl.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/pm.svg b/static/flags/pm.svg
new file mode 100644
index 0000000..19a9330
--- /dev/null
+++ b/static/flags/pm.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/pn.svg b/static/flags/pn.svg
new file mode 100644
index 0000000..209ea71
--- /dev/null
+++ b/static/flags/pn.svg
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pr.svg b/static/flags/pr.svg
new file mode 100644
index 0000000..ec51831
--- /dev/null
+++ b/static/flags/pr.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ps.svg b/static/flags/ps.svg
new file mode 100644
index 0000000..362d435
--- /dev/null
+++ b/static/flags/ps.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/pt.svg b/static/flags/pt.svg
new file mode 100644
index 0000000..2767cd4
--- /dev/null
+++ b/static/flags/pt.svg
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/pw.svg b/static/flags/pw.svg
new file mode 100644
index 0000000..9f89c5f
--- /dev/null
+++ b/static/flags/pw.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/py.svg b/static/flags/py.svg
new file mode 100644
index 0000000..abccd87
--- /dev/null
+++ b/static/flags/py.svg
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/qa.svg b/static/flags/qa.svg
new file mode 100644
index 0000000..901f3fa
--- /dev/null
+++ b/static/flags/qa.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/re.svg b/static/flags/re.svg
new file mode 100644
index 0000000..64e788e
--- /dev/null
+++ b/static/flags/re.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ro.svg b/static/flags/ro.svg
new file mode 100644
index 0000000..fda0f7b
--- /dev/null
+++ b/static/flags/ro.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/rs.svg b/static/flags/rs.svg
new file mode 100644
index 0000000..6d4f74d
--- /dev/null
+++ b/static/flags/rs.svg
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ru.svg b/static/flags/ru.svg
new file mode 100644
index 0000000..cf24301
--- /dev/null
+++ b/static/flags/ru.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/rw.svg b/static/flags/rw.svg
new file mode 100644
index 0000000..06e26ae
--- /dev/null
+++ b/static/flags/rw.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sa.svg b/static/flags/sa.svg
new file mode 100644
index 0000000..596cf48
--- /dev/null
+++ b/static/flags/sa.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sb.svg b/static/flags/sb.svg
new file mode 100644
index 0000000..6066f94
--- /dev/null
+++ b/static/flags/sb.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sc.svg b/static/flags/sc.svg
new file mode 100644
index 0000000..9a46b36
--- /dev/null
+++ b/static/flags/sc.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/sd.svg b/static/flags/sd.svg
new file mode 100644
index 0000000..12818b4
--- /dev/null
+++ b/static/flags/sd.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/se.svg b/static/flags/se.svg
new file mode 100644
index 0000000..8ba745a
--- /dev/null
+++ b/static/flags/se.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/sg.svg b/static/flags/sg.svg
new file mode 100644
index 0000000..c4dd4ac
--- /dev/null
+++ b/static/flags/sg.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sh-ac.svg b/static/flags/sh-ac.svg
new file mode 100644
index 0000000..c43b301
--- /dev/null
+++ b/static/flags/sh-ac.svg
@@ -0,0 +1,689 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sh-hl.svg b/static/flags/sh-hl.svg
new file mode 100644
index 0000000..2150bf6
--- /dev/null
+++ b/static/flags/sh-hl.svg
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sh-ta.svg b/static/flags/sh-ta.svg
new file mode 100644
index 0000000..ba39063
--- /dev/null
+++ b/static/flags/sh-ta.svg
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sh.svg b/static/flags/sh.svg
new file mode 100644
index 0000000..7aba0ae
--- /dev/null
+++ b/static/flags/sh.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/si.svg b/static/flags/si.svg
new file mode 100644
index 0000000..1bbdd94
--- /dev/null
+++ b/static/flags/si.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sj.svg b/static/flags/sj.svg
new file mode 100644
index 0000000..bb2799c
--- /dev/null
+++ b/static/flags/sj.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/sk.svg b/static/flags/sk.svg
new file mode 100644
index 0000000..676018e
--- /dev/null
+++ b/static/flags/sk.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sl.svg b/static/flags/sl.svg
new file mode 100644
index 0000000..a07baf7
--- /dev/null
+++ b/static/flags/sl.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/sm.svg b/static/flags/sm.svg
new file mode 100644
index 0000000..e41d2f7
--- /dev/null
+++ b/static/flags/sm.svg
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sn.svg b/static/flags/sn.svg
new file mode 100644
index 0000000..7c0673d
--- /dev/null
+++ b/static/flags/sn.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/so.svg b/static/flags/so.svg
new file mode 100644
index 0000000..a581ac6
--- /dev/null
+++ b/static/flags/so.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sr.svg b/static/flags/sr.svg
new file mode 100644
index 0000000..5e71c40
--- /dev/null
+++ b/static/flags/sr.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/ss.svg b/static/flags/ss.svg
new file mode 100644
index 0000000..b257aa0
--- /dev/null
+++ b/static/flags/ss.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/st.svg b/static/flags/st.svg
new file mode 100644
index 0000000..1294bcb
--- /dev/null
+++ b/static/flags/st.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sv.svg b/static/flags/sv.svg
new file mode 100644
index 0000000..cbc674a
--- /dev/null
+++ b/static/flags/sv.svg
@@ -0,0 +1,593 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sx.svg b/static/flags/sx.svg
new file mode 100644
index 0000000..ac78561
--- /dev/null
+++ b/static/flags/sx.svg
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/sy.svg b/static/flags/sy.svg
new file mode 100644
index 0000000..97c05cf
--- /dev/null
+++ b/static/flags/sy.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/sz.svg b/static/flags/sz.svg
new file mode 100644
index 0000000..eb538e4
--- /dev/null
+++ b/static/flags/sz.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tc.svg b/static/flags/tc.svg
new file mode 100644
index 0000000..1258971
--- /dev/null
+++ b/static/flags/tc.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/td.svg b/static/flags/td.svg
new file mode 100644
index 0000000..fa3bd92
--- /dev/null
+++ b/static/flags/td.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/tf.svg b/static/flags/tf.svg
new file mode 100644
index 0000000..fba2335
--- /dev/null
+++ b/static/flags/tf.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tg.svg b/static/flags/tg.svg
new file mode 100644
index 0000000..9d6ea6c
--- /dev/null
+++ b/static/flags/tg.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/th.svg b/static/flags/th.svg
new file mode 100644
index 0000000..1e93a61
--- /dev/null
+++ b/static/flags/th.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/tj.svg b/static/flags/tj.svg
new file mode 100644
index 0000000..f8c9a03
--- /dev/null
+++ b/static/flags/tj.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tk.svg b/static/flags/tk.svg
new file mode 100644
index 0000000..05d3e86
--- /dev/null
+++ b/static/flags/tk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/tl.svg b/static/flags/tl.svg
new file mode 100644
index 0000000..3d0701a
--- /dev/null
+++ b/static/flags/tl.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tm.svg b/static/flags/tm.svg
new file mode 100644
index 0000000..4154ed7
--- /dev/null
+++ b/static/flags/tm.svg
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tn.svg b/static/flags/tn.svg
new file mode 100644
index 0000000..5735c19
--- /dev/null
+++ b/static/flags/tn.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/to.svg b/static/flags/to.svg
new file mode 100644
index 0000000..d072337
--- /dev/null
+++ b/static/flags/to.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tr.svg b/static/flags/tr.svg
new file mode 100644
index 0000000..b96da21
--- /dev/null
+++ b/static/flags/tr.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/tt.svg b/static/flags/tt.svg
new file mode 100644
index 0000000..bc24938
--- /dev/null
+++ b/static/flags/tt.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/tv.svg b/static/flags/tv.svg
new file mode 100644
index 0000000..675210e
--- /dev/null
+++ b/static/flags/tv.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tw.svg b/static/flags/tw.svg
new file mode 100644
index 0000000..57fd98b
--- /dev/null
+++ b/static/flags/tw.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/tz.svg b/static/flags/tz.svg
new file mode 100644
index 0000000..a2cfbca
--- /dev/null
+++ b/static/flags/tz.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/ua.svg b/static/flags/ua.svg
new file mode 100644
index 0000000..a339eb1
--- /dev/null
+++ b/static/flags/ua.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/static/flags/ug.svg b/static/flags/ug.svg
new file mode 100644
index 0000000..520eee5
--- /dev/null
+++ b/static/flags/ug.svg
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/um.svg b/static/flags/um.svg
new file mode 100644
index 0000000..9e9edda
--- /dev/null
+++ b/static/flags/um.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/un.svg b/static/flags/un.svg
new file mode 100644
index 0000000..632bbb4
--- /dev/null
+++ b/static/flags/un.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/us.svg b/static/flags/us.svg
new file mode 100644
index 0000000..9cfd0c9
--- /dev/null
+++ b/static/flags/us.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/uy.svg b/static/flags/uy.svg
new file mode 100644
index 0000000..62c36f8
--- /dev/null
+++ b/static/flags/uy.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/uz.svg b/static/flags/uz.svg
new file mode 100644
index 0000000..0ccca1b
--- /dev/null
+++ b/static/flags/uz.svg
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/va.svg b/static/flags/va.svg
new file mode 100644
index 0000000..3e297d6
--- /dev/null
+++ b/static/flags/va.svg
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/vc.svg b/static/flags/vc.svg
new file mode 100644
index 0000000..f26c2d8
--- /dev/null
+++ b/static/flags/vc.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/static/flags/ve.svg b/static/flags/ve.svg
new file mode 100644
index 0000000..314e7f5
--- /dev/null
+++ b/static/flags/ve.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/vg.svg b/static/flags/vg.svg
new file mode 100644
index 0000000..ac90088
--- /dev/null
+++ b/static/flags/vg.svg
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/vi.svg b/static/flags/vi.svg
new file mode 100644
index 0000000..d88d68f
--- /dev/null
+++ b/static/flags/vi.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/vn.svg b/static/flags/vn.svg
new file mode 100644
index 0000000..7e4bac8
--- /dev/null
+++ b/static/flags/vn.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/vu.svg b/static/flags/vu.svg
new file mode 100644
index 0000000..326d29e
--- /dev/null
+++ b/static/flags/vu.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/wf.svg b/static/flags/wf.svg
new file mode 100644
index 0000000..054c57d
--- /dev/null
+++ b/static/flags/wf.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/ws.svg b/static/flags/ws.svg
new file mode 100644
index 0000000..0e758a7
--- /dev/null
+++ b/static/flags/ws.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/xk.svg b/static/flags/xk.svg
new file mode 100644
index 0000000..0e8958d
--- /dev/null
+++ b/static/flags/xk.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/xx.svg b/static/flags/xx.svg
new file mode 100644
index 0000000..9333be3
--- /dev/null
+++ b/static/flags/xx.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/flags/ye.svg b/static/flags/ye.svg
new file mode 100644
index 0000000..1c9e6d6
--- /dev/null
+++ b/static/flags/ye.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/static/flags/yt.svg b/static/flags/yt.svg
new file mode 100644
index 0000000..e7776b3
--- /dev/null
+++ b/static/flags/yt.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/static/flags/za.svg b/static/flags/za.svg
new file mode 100644
index 0000000..d563adb
--- /dev/null
+++ b/static/flags/za.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/zm.svg b/static/flags/zm.svg
new file mode 100644
index 0000000..360f37a
--- /dev/null
+++ b/static/flags/zm.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/flags/zw.svg b/static/flags/zw.svg
new file mode 100644
index 0000000..93aac4f
--- /dev/null
+++ b/static/flags/zw.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/icon-192x192.png b/static/icon-192x192.png
new file mode 100644
index 0000000..203f490
Binary files /dev/null and b/static/icon-192x192.png differ
diff --git a/static/icon-512x512.png b/static/icon-512x512.png
new file mode 100644
index 0000000..8c611a2
Binary files /dev/null and b/static/icon-512x512.png differ
diff --git a/static/monaco_high_res.jpg b/static/monaco_high_res.jpg
new file mode 100644
index 0000000..7fa65f3
Binary files /dev/null and b/static/monaco_high_res.jpg differ
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..b6dd667
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/supabase/docker/kong.yml b/supabase/docker/kong.yml
new file mode 100644
index 0000000..10b19eb
--- /dev/null
+++ b/supabase/docker/kong.yml
@@ -0,0 +1,196 @@
+_format_version: "2.1"
+_transform: true
+
+###
+### Consumers / Users
+###
+consumers:
+ - username: ANON
+ keyauth_credentials:
+ - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.b_lMH2mc5km7S9Lw_sRGGqE9IeiahYu-caevDcacKiY
+ - username: SERVICE_ROLE
+ keyauth_credentials:
+ - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.kcyKZAiwnnBG9t6IVGO17bcVw574pVynTHYVdF4q-p0
+
+###
+### Access Control Lists
+###
+acls:
+ - consumer: ANON
+ group: anon
+ - consumer: SERVICE_ROLE
+ group: admin
+
+###
+### API Routes
+###
+services:
+ ## Redirect /auth/verify to SvelteKit app for email links
+ - name: auth-verify-redirect
+ url: http://portal:3000/auth/verify
+ routes:
+ - name: auth-verify-redirect
+ strip_path: false
+ paths:
+ - /auth/verify
+ preserve_host: false
+ plugins:
+ - name: cors
+
+ ## Auth Service (GoTrue)
+ - name: auth-v1-open
+ url: http://auth:9999/verify
+ routes:
+ - name: auth-v1-open
+ strip_path: true
+ paths:
+ - /auth/v1/verify
+ plugins:
+ - name: cors
+
+ - name: auth-v1-open-callback
+ url: http://auth:9999/callback
+ routes:
+ - name: auth-v1-open-callback
+ strip_path: true
+ paths:
+ - /auth/v1/callback
+ plugins:
+ - name: cors
+
+ - name: auth-v1-open-authorize
+ url: http://auth:9999/authorize
+ routes:
+ - name: auth-v1-open-authorize
+ strip_path: true
+ paths:
+ - /auth/v1/authorize
+ plugins:
+ - name: cors
+
+ - name: auth-v1
+ url: http://auth:9999/
+ routes:
+ - name: auth-v1
+ strip_path: true
+ paths:
+ - /auth/v1/
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
+ - anon
+
+ ## REST Service (PostgREST)
+ - name: rest-v1
+ url: http://rest:3000/
+ routes:
+ - name: rest-v1
+ strip_path: true
+ paths:
+ - /rest/v1/
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
+ - anon
+
+ ## Realtime Service
+ - name: realtime-v1-ws
+ url: http://realtime:4000/socket
+ routes:
+ - name: realtime-v1-ws
+ strip_path: true
+ paths:
+ - /realtime/v1/websocket
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
+ - anon
+
+ - name: realtime-v1
+ url: http://realtime:4000/
+ routes:
+ - name: realtime-v1
+ strip_path: true
+ paths:
+ - /realtime/v1/
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
+ - anon
+
+ ## Storage Service - Public objects (no auth required)
+ - name: storage-v1-public
+ url: http://storage:5000/object/public
+ routes:
+ - name: storage-v1-public
+ strip_path: true
+ paths:
+ - /storage/v1/object/public
+ plugins:
+ - name: cors
+
+ ## Storage Service - All other operations (auth required)
+ - name: storage-v1
+ url: http://storage:5000/
+ routes:
+ - name: storage-v1
+ strip_path: true
+ paths:
+ - /storage/v1/
+ plugins:
+ - name: cors
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
+ - anon
+
+ ## PostgreSQL Meta (for Studio)
+ - name: meta
+ url: http://meta:8080/
+ routes:
+ - name: meta
+ strip_path: true
+ paths:
+ - /pg/
+ plugins:
+ - name: key-auth
+ config:
+ hide_credentials: false
+ - name: acl
+ config:
+ hide_groups_header: true
+ allow:
+ - admin
diff --git a/supabase/fix_rls_now.sql b/supabase/fix_rls_now.sql
new file mode 100644
index 0000000..09a4a03
--- /dev/null
+++ b/supabase/fix_rls_now.sql
@@ -0,0 +1,146 @@
+-- ============================================
+-- IMMEDIATE FIX FOR RLS ISSUES
+-- Run this SQL directly in Supabase Studio SQL Editor
+-- ============================================
+
+-- =====================
+-- STEP 1: FIX STORAGE.OBJECTS POLICIES
+-- =====================
+
+-- Drop any existing service_role policies with various names
+DROP POLICY IF EXISTS "Service role can insert avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can update avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can delete avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can read avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_insert_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_update_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_delete_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_select_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_select" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_insert" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_update" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_delete" ON storage.objects;
+
+-- Create universal service_role policies for ALL storage operations
+CREATE POLICY "service_role_all_select" ON storage.objects
+FOR SELECT TO service_role
+USING (true);
+
+CREATE POLICY "service_role_all_insert" ON storage.objects
+FOR INSERT TO service_role
+WITH CHECK (true);
+
+CREATE POLICY "service_role_all_update" ON storage.objects
+FOR UPDATE TO service_role
+USING (true);
+
+CREATE POLICY "service_role_all_delete" ON storage.objects
+FOR DELETE TO service_role
+USING (true);
+
+-- Grant permissions
+GRANT ALL ON storage.objects TO service_role;
+GRANT ALL ON storage.buckets TO service_role;
+GRANT USAGE ON SCHEMA storage TO service_role;
+
+-- =====================
+-- STEP 2: FIX PUBLIC.MEMBERS POLICIES
+-- =====================
+
+-- Drop any existing service_role policies on members
+DROP POLICY IF EXISTS "service_role_all_members" ON public.members;
+DROP POLICY IF EXISTS "service_role_select_members" ON public.members;
+DROP POLICY IF EXISTS "service_role_insert_members" ON public.members;
+DROP POLICY IF EXISTS "service_role_update_members" ON public.members;
+DROP POLICY IF EXISTS "service_role_delete_members" ON public.members;
+
+-- Create universal service_role policy for members table
+CREATE POLICY "service_role_all_members" ON public.members
+FOR ALL TO service_role
+USING (true)
+WITH CHECK (true);
+
+-- Grant permissions
+GRANT ALL ON public.members TO service_role;
+
+-- =====================
+-- STEP 3: ENSURE STORAGE BUCKETS EXIST
+-- =====================
+
+-- Avatars bucket (public)
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'avatars',
+ 'avatars',
+ true,
+ 5242880,
+ ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+-- Documents bucket (public for direct URL access - visibility controlled at app level)
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'documents',
+ 'documents',
+ true,
+ 52428800,
+ ARRAY['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/json', 'image/jpeg', 'image/png', 'image/webp', 'image/gif']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+-- =====================
+-- STEP 4: TRY TO GRANT BYPASSRLS (may fail, that's OK)
+-- =====================
+
+DO $$
+BEGIN
+ ALTER ROLE service_role BYPASSRLS;
+ RAISE NOTICE 'SUCCESS: Granted BYPASSRLS to service_role';
+EXCEPTION
+ WHEN insufficient_privilege THEN
+ RAISE NOTICE 'INFO: Could not grant BYPASSRLS (using explicit policies instead)';
+ WHEN OTHERS THEN
+ RAISE NOTICE 'INFO: BYPASSRLS not needed or already set';
+END $$;
+
+-- =====================
+-- STEP 5: VERIFY SETUP
+-- =====================
+
+-- Check service_role policies on storage.objects
+SELECT
+ policyname,
+ permissive,
+ roles,
+ cmd,
+ qual,
+ with_check
+FROM pg_policies
+WHERE schemaname = 'storage'
+ AND tablename = 'objects'
+ AND 'service_role' = ANY(roles);
+
+-- Check service_role policies on public.members
+SELECT
+ policyname,
+ permissive,
+ roles,
+ cmd,
+ qual,
+ with_check
+FROM pg_policies
+WHERE schemaname = 'public'
+ AND tablename = 'members'
+ AND 'service_role' = ANY(roles);
+
+-- Check if service_role has BYPASSRLS
+SELECT rolname, rolbypassrls
+FROM pg_roles
+WHERE rolname = 'service_role';
diff --git a/supabase/migrations/001_initial_schema.sql b/supabase/migrations/001_initial_schema.sql
new file mode 100644
index 0000000..4c16f42
--- /dev/null
+++ b/supabase/migrations/001_initial_schema.sql
@@ -0,0 +1,786 @@
+-- Monaco USA Portal 2026 - Initial Database Schema
+-- Run this migration to set up all tables, views, triggers, and RLS policies
+
+-- ============================================
+-- MEMBERSHIP STATUSES (Admin-configurable)
+-- ============================================
+CREATE TABLE public.membership_statuses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#6b7280',
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default statuses
+INSERT INTO public.membership_statuses (name, display_name, color, description, is_default, sort_order) VALUES
+ ('pending', 'Pending', '#eab308', 'New member, awaiting dues payment', true, 1),
+ ('active', 'Active', '#22c55e', 'Dues paid, full access', false, 2),
+ ('inactive', 'Inactive', '#6b7280', 'Lapsed membership or suspended', false, 3),
+ ('expired', 'Expired', '#ef4444', 'Membership terminated', false, 4);
+
+-- ============================================
+-- MEMBERSHIP TYPES (Admin-configurable pricing)
+-- ============================================
+CREATE TABLE public.membership_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ annual_dues DECIMAL(10,2) NOT NULL,
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE,
+ is_active BOOLEAN DEFAULT TRUE,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default membership types
+INSERT INTO public.membership_types (name, display_name, annual_dues, description, is_default, sort_order) VALUES
+ ('regular', 'Regular Member', 50.00, 'Standard individual membership', true, 1),
+ ('student', 'Student', 25.00, 'For students with valid ID', false, 2),
+ ('senior', 'Senior (65+)', 35.00, 'For members 65 years and older', false, 3),
+ ('family', 'Family', 75.00, 'Household membership', false, 4),
+ ('honorary', 'Honorary Member', 0.00, 'Granted by the board', false, 5);
+
+-- ============================================
+-- MEMBERS TABLE
+-- ============================================
+CREATE TABLE public.members (
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ member_id TEXT UNIQUE NOT NULL,
+
+ -- Personal Info
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ phone TEXT NOT NULL,
+ date_of_birth DATE NOT NULL,
+ address TEXT NOT NULL,
+ nationality TEXT[] NOT NULL DEFAULT '{}',
+
+ -- Membership
+ role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')),
+ membership_status_id UUID REFERENCES public.membership_statuses(id),
+ membership_type_id UUID REFERENCES public.membership_types(id),
+ member_since DATE DEFAULT CURRENT_DATE,
+
+ -- Profile
+ avatar_url TEXT,
+
+ -- Admin
+ notes TEXT,
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Auto-generate member_id trigger
+CREATE OR REPLACE FUNCTION generate_member_id()
+RETURNS TRIGGER AS $$
+DECLARE
+ next_num INTEGER;
+BEGIN
+ SELECT COALESCE(MAX(CAST(SUBSTRING(member_id FROM 6) AS INTEGER)), 0) + 1
+ INTO next_num
+ FROM public.members;
+
+ NEW.member_id := 'MUSA-' || LPAD(next_num::TEXT, 4, '0');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_member_id
+ BEFORE INSERT ON public.members
+ FOR EACH ROW
+ WHEN (NEW.member_id IS NULL)
+ EXECUTE FUNCTION generate_member_id();
+
+-- Update timestamp trigger
+CREATE OR REPLACE FUNCTION update_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER members_updated_at
+ BEFORE UPDATE ON public.members
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- DUES PAYMENTS
+-- ============================================
+CREATE TABLE public.dues_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ amount DECIMAL(10,2) NOT NULL,
+ currency TEXT DEFAULT 'EUR',
+ payment_date DATE NOT NULL,
+ due_date DATE NOT NULL,
+ payment_method TEXT DEFAULT 'bank_transfer',
+ reference TEXT,
+ notes TEXT,
+
+ recorded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Auto-calculate due_date (1 year from payment)
+CREATE OR REPLACE FUNCTION calculate_due_date()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF NEW.due_date IS NULL THEN
+ NEW.due_date := NEW.payment_date + INTERVAL '1 year';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_due_date
+ BEFORE INSERT ON public.dues_payments
+ FOR EACH ROW
+ EXECUTE FUNCTION calculate_due_date();
+
+-- Auto-update member status to active after payment
+CREATE OR REPLACE FUNCTION update_member_status_on_payment()
+RETURNS TRIGGER AS $$
+DECLARE
+ active_status_id UUID;
+BEGIN
+ SELECT id INTO active_status_id
+ FROM public.membership_statuses
+ WHERE name = 'active';
+
+ UPDATE public.members
+ SET membership_status_id = active_status_id,
+ updated_at = NOW()
+ WHERE id = NEW.member_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER activate_member_on_payment
+ AFTER INSERT ON public.dues_payments
+ FOR EACH ROW
+ EXECUTE FUNCTION update_member_status_on_payment();
+
+-- ============================================
+-- EVENT TYPES (Admin-configurable)
+-- ============================================
+CREATE TABLE public.event_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#3b82f6',
+ icon TEXT,
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default event types
+INSERT INTO public.event_types (name, display_name, color, icon, sort_order) VALUES
+ ('social', 'Social Event', '#10b981', 'party-popper', 1),
+ ('meeting', 'Meeting', '#6366f1', 'users', 2),
+ ('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake', 3),
+ ('workshop', 'Workshop', '#8b5cf6', 'graduation-cap', 4),
+ ('gala', 'Gala/Formal', '#ec4899', 'sparkles', 5),
+ ('other', 'Other', '#6b7280', 'calendar', 6);
+
+-- ============================================
+-- EVENTS
+-- ============================================
+CREATE TABLE public.events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Basic Info
+ title TEXT NOT NULL,
+ description TEXT,
+ event_type_id UUID REFERENCES public.event_types(id),
+
+ -- Date/Time
+ start_datetime TIMESTAMPTZ NOT NULL,
+ end_datetime TIMESTAMPTZ NOT NULL,
+ all_day BOOLEAN DEFAULT FALSE,
+ timezone TEXT DEFAULT 'Europe/Monaco',
+
+ -- Location
+ location TEXT,
+ location_url TEXT,
+
+ -- Capacity
+ max_attendees INTEGER,
+ max_guests_per_member INTEGER DEFAULT 1,
+
+ -- Pricing
+ is_paid BOOLEAN DEFAULT FALSE,
+ member_price DECIMAL(10,2) DEFAULT 0,
+ non_member_price DECIMAL(10,2) DEFAULT 0,
+ pricing_notes TEXT,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+ status TEXT NOT NULL DEFAULT 'published'
+ CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
+
+ -- Media
+ cover_image_url TEXT,
+
+ -- Meta
+ created_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TRIGGER events_updated_at
+ BEFORE UPDATE ON public.events
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- EVENT RSVPs (Members)
+-- ============================================
+CREATE TABLE public.event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+ notes TEXT,
+
+ -- Payment
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+ checked_in_at TIMESTAMPTZ,
+ checked_in_by UUID REFERENCES public.members(id),
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, member_id)
+);
+
+CREATE TRIGGER event_rsvps_updated_at
+ BEFORE UPDATE ON public.event_rsvps
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- EVENT RSVPs (Public/Non-members)
+-- ============================================
+CREATE TABLE public.event_rsvps_public (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+
+ full_name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ phone TEXT,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+
+ -- Payment
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, email)
+);
+
+CREATE TRIGGER event_rsvps_public_updated_at
+ BEFORE UPDATE ON public.event_rsvps_public
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- DOCUMENT CATEGORIES
+-- ============================================
+CREATE TABLE public.document_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ description TEXT,
+ icon TEXT,
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default categories
+INSERT INTO public.document_categories (name, display_name, icon, sort_order) VALUES
+ ('meeting_minutes', 'Meeting Minutes', 'file-text', 1),
+ ('governance', 'Governance & Bylaws', 'scale', 2),
+ ('legal', 'Legal Documents', 'briefcase', 3),
+ ('financial', 'Financial Reports', 'dollar-sign', 4),
+ ('member_resources', 'Member Resources', 'book-open', 5),
+ ('forms', 'Forms & Templates', 'clipboard', 6),
+ ('other', 'Other Documents', 'file', 7);
+
+-- ============================================
+-- DOCUMENTS
+-- ============================================
+CREATE TABLE public.documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title TEXT NOT NULL,
+ description TEXT,
+ category_id UUID REFERENCES public.document_categories(id),
+
+ -- File Info
+ file_path TEXT NOT NULL,
+ file_name TEXT NOT NULL,
+ file_size INTEGER NOT NULL,
+ mime_type TEXT NOT NULL,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+ allowed_member_ids UUID[],
+
+ -- Version tracking
+ version INTEGER DEFAULT 1,
+ replaces_document_id UUID REFERENCES public.documents(id),
+
+ -- Meeting-specific fields
+ meeting_date DATE,
+ meeting_attendees UUID[],
+
+ -- Meta
+ uploaded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+CREATE TRIGGER documents_updated_at
+ BEFORE UPDATE ON public.documents
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- APP SETTINGS (Unified key-value store)
+-- ============================================
+CREATE TABLE public.app_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category TEXT NOT NULL,
+ setting_key TEXT NOT NULL,
+ setting_value JSONB NOT NULL,
+ setting_type TEXT NOT NULL DEFAULT 'text'
+ CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')),
+ display_name TEXT NOT NULL,
+ description TEXT,
+ is_public BOOLEAN DEFAULT FALSE,
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id),
+
+ UNIQUE(category, setting_key)
+);
+
+-- Default settings
+INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
+ -- Organization
+ ('organization', 'association_name', '"Monaco USA"', 'text', 'Association Name', 'Official name of the association', true),
+ ('organization', 'tagline', '"Americans in Monaco"', 'text', 'Tagline', 'Association tagline shown on login', true),
+ ('organization', 'contact_email', '"contact@monacousa.org"', 'text', 'Contact Email', 'Public contact email', true),
+ ('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true),
+
+ -- Dues
+ ('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues', false),
+ ('dues', 'payment_account_holder', '"ASSOCIATION MONACO USA"', 'text', 'Account Holder', 'Bank account holder name', false),
+ ('dues', 'payment_bank_name', '"Credit Foncier de Monaco"', 'text', 'Bank Name', 'Name of the bank', false),
+ ('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due to send reminders', false),
+ ('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due before auto-inactive', false),
+ ('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Auto set inactive after grace period', false),
+
+ -- System
+ ('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put portal in maintenance mode', false),
+ ('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', false);
+
+-- ============================================
+-- EMAIL TEMPLATES
+-- ============================================
+CREATE TABLE public.email_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ template_key TEXT UNIQUE NOT NULL,
+ template_name TEXT NOT NULL,
+ category TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ is_system BOOLEAN DEFAULT FALSE,
+ variables_schema JSONB,
+ preview_data JSONB,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id)
+);
+
+CREATE TRIGGER email_templates_updated_at
+ BEFORE UPDATE ON public.email_templates
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- EMAIL LOGS
+-- ============================================
+CREATE TABLE public.email_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ recipient_id UUID REFERENCES public.members(id),
+ recipient_email TEXT NOT NULL,
+ recipient_name TEXT,
+ template_key TEXT,
+ subject TEXT NOT NULL,
+ email_type TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')),
+ provider TEXT,
+ provider_message_id TEXT,
+ opened_at TIMESTAMPTZ,
+ clicked_at TIMESTAMPTZ,
+ error_message TEXT,
+ retry_count INTEGER DEFAULT 0,
+ template_variables JSONB,
+ sent_by UUID REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sent_at TIMESTAMPTZ,
+ delivered_at TIMESTAMPTZ
+);
+
+-- ============================================
+-- VIEWS
+-- ============================================
+
+-- Members with dues status
+CREATE VIEW public.members_with_dues AS
+SELECT
+ m.*,
+ ms.name as status_name,
+ ms.display_name as status_display_name,
+ ms.color as status_color,
+ mt.display_name as membership_type_name,
+ mt.annual_dues,
+ dp.last_payment_date,
+ dp.current_due_date,
+ CASE
+ WHEN dp.current_due_date IS NULL THEN 'never_paid'
+ WHEN dp.current_due_date < CURRENT_DATE THEN 'overdue'
+ WHEN dp.current_due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
+ ELSE 'current'
+ END as dues_status,
+ CASE
+ WHEN dp.current_due_date < CURRENT_DATE
+ THEN (CURRENT_DATE - dp.current_due_date)::INTEGER
+ ELSE NULL
+ END as days_overdue,
+ CASE
+ WHEN dp.current_due_date >= CURRENT_DATE
+ THEN (dp.current_due_date - CURRENT_DATE)::INTEGER
+ ELSE NULL
+ END as days_until_due
+FROM public.members m
+LEFT JOIN public.membership_statuses ms ON m.membership_status_id = ms.id
+LEFT JOIN public.membership_types mt ON m.membership_type_id = mt.id
+LEFT JOIN LATERAL (
+ SELECT
+ payment_date as last_payment_date,
+ due_date as current_due_date
+ FROM public.dues_payments
+ WHERE member_id = m.id
+ ORDER BY due_date DESC
+ LIMIT 1
+) dp ON true;
+
+-- Events with attendee counts
+CREATE VIEW public.events_with_counts AS
+SELECT
+ e.*,
+ et.display_name as event_type_name,
+ et.color as event_type_color,
+ et.icon as event_type_icon,
+ COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0) as total_attendees,
+ COALESCE(member_rsvps.confirmed_count, 0) as member_count,
+ COALESCE(public_rsvps.confirmed_count, 0) as non_member_count,
+ COALESCE(member_rsvps.waitlist_count, 0) +
+ COALESCE(public_rsvps.waitlist_count, 0) as waitlist_count,
+ CASE
+ WHEN e.max_attendees IS NULL THEN FALSE
+ WHEN (COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0)) >= e.max_attendees THEN TRUE
+ ELSE FALSE
+ END as is_full
+FROM public.events e
+LEFT JOIN public.event_types et ON e.event_type_id = et.id
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps
+ WHERE event_id = e.id
+) member_rsvps ON true
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps_public
+ WHERE event_id = e.id
+) public_rsvps ON true;
+
+-- ============================================
+-- ROW LEVEL SECURITY
+-- ============================================
+
+-- Enable RLS on all tables
+ALTER TABLE public.members ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.dues_payments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.events ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.event_rsvps ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.event_rsvps_public ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.email_templates ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.email_logs ENABLE ROW LEVEL SECURITY;
+
+-- MEMBERS POLICIES
+CREATE POLICY "Members viewable by authenticated users"
+ ON public.members FOR SELECT
+ TO authenticated
+ USING (true);
+
+CREATE POLICY "Users can update own profile"
+ ON public.members FOR UPDATE
+ TO authenticated
+ USING (auth.uid() = id);
+
+CREATE POLICY "Admins can insert members"
+ ON public.members FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ OR auth.uid() = id
+ );
+
+CREATE POLICY "Admins can delete members"
+ ON public.members FOR DELETE
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- DUES PAYMENTS POLICIES
+CREATE POLICY "Own payments viewable"
+ ON public.dues_payments FOR SELECT
+ TO authenticated
+ USING (
+ member_id = auth.uid()
+ OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Board can record payments"
+ ON public.dues_payments FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- EVENTS POLICIES
+CREATE POLICY "Events viewable based on visibility"
+ ON public.events FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ );
+
+CREATE POLICY "Public events viewable by anyone"
+ ON public.events FOR SELECT
+ TO anon
+ USING (visibility = 'public' AND status = 'published');
+
+CREATE POLICY "Board can manage events"
+ ON public.events FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- EVENT RSVPs POLICIES
+CREATE POLICY "RSVPs viewable by member and board"
+ ON public.event_rsvps FOR SELECT
+ TO authenticated
+ USING (
+ member_id = auth.uid()
+ OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Members can manage own RSVPs"
+ ON public.event_rsvps FOR ALL
+ TO authenticated
+ USING (member_id = auth.uid())
+ WITH CHECK (member_id = auth.uid());
+
+CREATE POLICY "Board can manage all RSVPs"
+ ON public.event_rsvps FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- PUBLIC RSVPs POLICIES
+CREATE POLICY "Public RSVPs viewable by board"
+ ON public.event_rsvps_public FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Anyone can create public RSVP"
+ ON public.event_rsvps_public FOR INSERT
+ TO anon, authenticated
+ WITH CHECK (true);
+
+CREATE POLICY "Board can manage public RSVPs"
+ ON public.event_rsvps_public FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- DOCUMENTS POLICIES
+CREATE POLICY "Documents viewable based on visibility"
+ ON public.documents FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ OR (allowed_member_ids IS NOT NULL AND auth.uid() = ANY(allowed_member_ids))
+ );
+
+CREATE POLICY "Board can upload documents"
+ ON public.documents FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Admin can manage all documents"
+ ON public.documents FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- APP SETTINGS POLICIES
+CREATE POLICY "Public settings viewable by anyone"
+ ON public.app_settings FOR SELECT
+ USING (is_public = true);
+
+CREATE POLICY "All settings viewable by admin"
+ ON public.app_settings FOR SELECT
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+CREATE POLICY "Admin can manage settings"
+ ON public.app_settings FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- EMAIL TEMPLATES POLICIES
+CREATE POLICY "Admin can manage email templates"
+ ON public.email_templates FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- EMAIL LOGS POLICIES
+CREATE POLICY "Own email logs viewable"
+ ON public.email_logs FOR SELECT
+ TO authenticated
+ USING (
+ recipient_id = auth.uid()
+ OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+CREATE POLICY "Admin can manage email logs"
+ ON public.email_logs FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- ============================================
+-- INDEXES
+-- ============================================
+CREATE INDEX idx_members_email ON public.members(email);
+CREATE INDEX idx_members_member_id ON public.members(member_id);
+CREATE INDEX idx_members_role ON public.members(role);
+CREATE INDEX idx_members_status ON public.members(membership_status_id);
+
+CREATE INDEX idx_dues_payments_member ON public.dues_payments(member_id);
+CREATE INDEX idx_dues_payments_date ON public.dues_payments(payment_date DESC);
+
+CREATE INDEX idx_events_start ON public.events(start_datetime);
+CREATE INDEX idx_events_visibility ON public.events(visibility);
+CREATE INDEX idx_events_status ON public.events(status);
+
+CREATE INDEX idx_event_rsvps_event ON public.event_rsvps(event_id);
+CREATE INDEX idx_event_rsvps_member ON public.event_rsvps(member_id);
+
+CREATE INDEX idx_documents_category ON public.documents(category_id);
+CREATE INDEX idx_documents_visibility ON public.documents(visibility);
+
+CREATE INDEX idx_app_settings_category ON public.app_settings(category, setting_key);
+
+CREATE INDEX idx_email_logs_recipient ON public.email_logs(recipient_id);
+CREATE INDEX idx_email_logs_status ON public.email_logs(status);
+CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC);
diff --git a/supabase/migrations/002_admin_integrations_settings.sql b/supabase/migrations/002_admin_integrations_settings.sql
new file mode 100644
index 0000000..661f63d
--- /dev/null
+++ b/supabase/migrations/002_admin_integrations_settings.sql
@@ -0,0 +1,35 @@
+-- ============================================
+-- ADMIN INTEGRATION SETTINGS
+-- SMTP, S3/MinIO, and expanded system settings
+-- ============================================
+
+-- Add SMTP settings
+INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
+ -- Email/SMTP Configuration
+ ('email', 'smtp_host', '""', 'text', 'SMTP Host', 'SMTP server hostname (e.g., smtp.gmail.com)', false),
+ ('email', 'smtp_port', '587', 'number', 'SMTP Port', 'SMTP server port (25, 465, 587)', false),
+ ('email', 'smtp_secure', 'true', 'boolean', 'Use TLS/SSL', 'Enable secure connection (recommended)', false),
+ ('email', 'smtp_username', '""', 'text', 'SMTP Username', 'SMTP authentication username', false),
+ ('email', 'smtp_password', '""', 'text', 'SMTP Password', 'SMTP authentication password', false),
+ ('email', 'smtp_from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address', false),
+ ('email', 'smtp_from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender display name', false),
+ ('email', 'smtp_reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address', false),
+ ('email', 'smtp_enabled', 'false', 'boolean', 'Enable Email', 'Enable sending emails via SMTP', false),
+
+ -- S3/MinIO Storage Configuration
+ ('storage', 's3_endpoint', '""', 'text', 'S3 Endpoint', 'S3-compatible endpoint URL (e.g., http://minio:9000)', false),
+ ('storage', 's3_bucket', '"monacousa-documents"', 'text', 'Bucket Name', 'S3 bucket name for file storage', false),
+ ('storage', 's3_access_key', '""', 'text', 'Access Key', 'S3 access key ID', false),
+ ('storage', 's3_secret_key', '""', 'text', 'Secret Key', 'S3 secret access key', false),
+ ('storage', 's3_region', '"us-east-1"', 'text', 'Region', 'S3 region (use us-east-1 for MinIO)', false),
+ ('storage', 's3_use_ssl', 'false', 'boolean', 'Use SSL', 'Enable SSL for S3 connections', false),
+ ('storage', 's3_force_path_style', 'true', 'boolean', 'Force Path Style', 'Use path-style URLs (required for MinIO)', false),
+ ('storage', 's3_enabled', 'false', 'boolean', 'Enable S3 Storage', 'Use external S3 instead of Supabase Storage', false),
+
+ -- Additional System Settings
+ ('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false),
+ ('system', 'allowed_file_types', '["pdf","doc","docx","xls","xlsx","ppt","pptx","txt","jpg","jpeg","png","webp"]', 'array', 'Allowed File Types', 'Allowed file extensions for uploads', false),
+ ('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false),
+ ('system', 'enable_public_events', 'true', 'boolean', 'Enable Public Events', 'Allow non-members to view public events', false),
+ ('system', 'enable_public_rsvp', 'true', 'boolean', 'Enable Public RSVP', 'Allow non-members to RSVP to public events', false)
+ON CONFLICT (category, setting_key) DO NOTHING;
diff --git a/supabase/migrations/003_storage_buckets_and_audit.sql b/supabase/migrations/003_storage_buckets_and_audit.sql
new file mode 100644
index 0000000..f82d507
--- /dev/null
+++ b/supabase/migrations/003_storage_buckets_and_audit.sql
@@ -0,0 +1,504 @@
+-- Monaco USA Portal 2026
+-- Migration 003: Storage Buckets and Audit Logging
+-- ================================================
+
+-- ============================================
+-- STORAGE BUCKETS
+-- ============================================
+
+-- Documents bucket (public for direct URL access - visibility controlled at app level)
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'documents',
+ 'documents',
+ true,
+ 52428800, -- 50MB
+ ARRAY['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/json', 'image/jpeg', 'image/png', 'image/webp', 'image/gif']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+-- Avatars bucket (public for display)
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'avatars',
+ 'avatars',
+ true,
+ 5242880, -- 5MB
+ ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+-- Event images bucket (public for display)
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'event-images',
+ 'event-images',
+ true,
+ 10485760, -- 10MB
+ ARRAY['image/jpeg', 'image/png', 'image/webp']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
+
+-- ============================================
+-- STORAGE POLICIES
+-- ============================================
+
+-- Documents bucket policies
+DROP POLICY IF EXISTS "documents_read_policy" ON storage.objects;
+CREATE POLICY "documents_read_policy" ON storage.objects FOR SELECT
+USING (bucket_id = 'documents' AND auth.role() = 'authenticated');
+
+DROP POLICY IF EXISTS "documents_insert_policy" ON storage.objects;
+CREATE POLICY "documents_insert_policy" ON storage.objects FOR INSERT
+WITH CHECK (
+ bucket_id = 'documents'
+ AND auth.role() = 'authenticated'
+ AND EXISTS (
+ SELECT 1 FROM public.members
+ WHERE id = auth.uid()
+ AND role IN ('board', 'admin')
+ )
+);
+
+DROP POLICY IF EXISTS "documents_delete_policy" ON storage.objects;
+CREATE POLICY "documents_delete_policy" ON storage.objects FOR DELETE
+USING (
+ bucket_id = 'documents'
+ AND EXISTS (
+ SELECT 1 FROM public.members
+ WHERE id = auth.uid()
+ AND role = 'admin'
+ )
+);
+
+-- Avatars bucket policies (public read, user-specific write)
+DROP POLICY IF EXISTS "avatars_read_policy" ON storage.objects;
+CREATE POLICY "avatars_read_policy" ON storage.objects FOR SELECT
+USING (bucket_id = 'avatars');
+
+DROP POLICY IF EXISTS "avatars_insert_policy" ON storage.objects;
+CREATE POLICY "avatars_insert_policy" ON storage.objects FOR INSERT
+WITH CHECK (
+ bucket_id = 'avatars'
+ AND auth.role() = 'authenticated'
+ AND (storage.foldername(name))[1] = auth.uid()::text
+);
+
+DROP POLICY IF EXISTS "avatars_update_policy" ON storage.objects;
+CREATE POLICY "avatars_update_policy" ON storage.objects FOR UPDATE
+USING (
+ bucket_id = 'avatars'
+ AND auth.role() = 'authenticated'
+ AND (storage.foldername(name))[1] = auth.uid()::text
+);
+
+DROP POLICY IF EXISTS "avatars_delete_policy" ON storage.objects;
+CREATE POLICY "avatars_delete_policy" ON storage.objects FOR DELETE
+USING (
+ bucket_id = 'avatars'
+ AND auth.role() = 'authenticated'
+ AND (storage.foldername(name))[1] = auth.uid()::text
+);
+
+-- Event images bucket policies
+DROP POLICY IF EXISTS "event_images_read_policy" ON storage.objects;
+CREATE POLICY "event_images_read_policy" ON storage.objects FOR SELECT
+USING (bucket_id = 'event-images');
+
+DROP POLICY IF EXISTS "event_images_insert_policy" ON storage.objects;
+CREATE POLICY "event_images_insert_policy" ON storage.objects FOR INSERT
+WITH CHECK (
+ bucket_id = 'event-images'
+ AND auth.role() = 'authenticated'
+ AND EXISTS (
+ SELECT 1 FROM public.members
+ WHERE id = auth.uid()
+ AND role IN ('board', 'admin')
+ )
+);
+
+DROP POLICY IF EXISTS "event_images_delete_policy" ON storage.objects;
+CREATE POLICY "event_images_delete_policy" ON storage.objects FOR DELETE
+USING (
+ bucket_id = 'event-images'
+ AND EXISTS (
+ SELECT 1 FROM public.members
+ WHERE id = auth.uid()
+ AND role IN ('board', 'admin')
+ )
+);
+
+-- ============================================
+-- AUDIT LOGS TABLE
+-- ============================================
+
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ user_email TEXT,
+ action TEXT NOT NULL,
+ resource_type TEXT,
+ resource_id TEXT,
+ details JSONB DEFAULT '{}',
+ ip_address TEXT,
+ user_agent TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Index for querying audit logs
+CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_type ON audit_logs(resource_type);
+CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
+
+-- RLS for audit logs (only admins can read, service role can write)
+ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS "audit_logs_read_admin" ON audit_logs;
+CREATE POLICY "audit_logs_read_admin" ON audit_logs FOR SELECT
+USING (
+ EXISTS (
+ SELECT 1 FROM public.members
+ WHERE id = auth.uid()
+ AND role = 'admin'
+ )
+);
+
+-- ============================================
+-- DEFAULT EMAIL TEMPLATES
+-- ============================================
+
+-- Insert default email templates if they don't exist
+-- Using Monaco-branded design matching the login screen
+INSERT INTO email_templates (template_key, template_name, category, subject, body_html, body_text, is_system)
+VALUES
+ (
+ 'welcome',
+ 'Welcome Email',
+ 'member',
+ 'Welcome to Monaco USA, {{first_name}}!',
+ '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Welcome to Monaco USA!
+ Dear {{first_name}},
+ We are thrilled to welcome you to the Monaco USA community! Your membership has been created and you can now access all member features.
+ To get started:
+
+ Set up your password using the separate email we sent
+ Complete your profile with your details
+ Explore upcoming events and connect with fellow members
+
+ If you have any questions, please don''t hesitate to reach out to our board members.
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+',
+ 'Welcome to Monaco USA, {{first_name}}! Your membership has been created. Please set up your password and complete your profile.',
+ true
+ ),
+ (
+ 'waitlist_promotion',
+ 'Waitlist Promotion',
+ 'event',
+ 'Great news! You''re confirmed for {{event_title}}',
+ '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ You''re In!
+ Dear {{first_name}},
+ Great news! A spot has opened up for {{event_title}} and you have been moved from the waitlist to confirmed!
+
+
Event Details
+
Date: {{event_date}}
+
Location: {{event_location}}
+
+ We look forward to seeing you there!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+',
+ 'Great news! A spot has opened up for {{event_title}} and you''ve been confirmed. See you on {{event_date}} at {{event_location}}!',
+ true
+ ),
+ (
+ 'rsvp_confirmation',
+ 'RSVP Confirmation',
+ 'event',
+ 'RSVP Confirmed: {{event_title}}',
+ '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ RSVP Confirmed!
+ Dear {{first_name}},
+ Your RSVP for {{event_title}} has been confirmed.
+
+
Event Details
+
Date: {{event_date}}
+
Time: {{event_time}}
+
Location: {{event_location}}
+
Guests: {{guest_count}}
+
+ We look forward to seeing you!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+',
+ 'Your RSVP for {{event_title}} is confirmed! See you on {{event_date}} at {{event_location}}.',
+ true
+ ),
+ (
+ 'payment_received',
+ 'Payment Received',
+ 'dues',
+ 'Payment Received - Monaco USA',
+ '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Payment Received
+ Dear {{first_name}},
+ We have received your payment. Thank you!
+
+
Payment Details
+
Amount: ${{amount}}
+
Date: {{payment_date}}
+
Reference: {{reference}}
+
+ Your membership dues are now paid through {{due_date}} .
+ Thank you for your continued support of Monaco USA!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+',
+ 'Payment of ${{amount}} received on {{payment_date}}. Your dues are paid through {{due_date}}. Thank you!',
+ true
+ ),
+ (
+ 'dues_reminder',
+ 'Dues Reminder',
+ 'dues',
+ 'Monaco USA Membership Dues Reminder',
+ '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Membership Dues Reminder
+ Dear {{first_name}},
+ This is a friendly reminder that your Monaco USA membership dues {{status}}.
+
+
Dues Details
+
Amount Due: ${{amount}}
+
Due Date: {{due_date}}
+
+ Please log in to your member portal to view payment instructions or contact the treasurer for assistance.
+ Thank you for your continued membership!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+',
+ 'Reminder: Your Monaco USA membership dues ({{amount}}) {{status}}. Due date: {{due_date}}. Please log in to your portal for payment instructions.',
+ true
+ )
+ON CONFLICT (template_key) DO NOTHING;
+
+-- Grant permissions
+GRANT SELECT, INSERT, UPDATE, DELETE ON audit_logs TO authenticated;
+GRANT ALL ON audit_logs TO service_role;
diff --git a/supabase/migrations/004_user_notification_preferences.sql b/supabase/migrations/004_user_notification_preferences.sql
new file mode 100644
index 0000000..1cb2ecd
--- /dev/null
+++ b/supabase/migrations/004_user_notification_preferences.sql
@@ -0,0 +1,102 @@
+-- User Notification Preferences
+-- Allows members to control what email notifications they receive
+
+-- Create user notification preferences table
+CREATE TABLE IF NOT EXISTS user_notification_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE UNIQUE,
+
+ -- Event notifications
+ email_event_rsvp_confirmation BOOLEAN DEFAULT true,
+ email_event_reminder BOOLEAN DEFAULT true,
+ email_event_updates BOOLEAN DEFAULT true,
+ email_waitlist_promotion BOOLEAN DEFAULT true,
+
+ -- Membership notifications
+ email_dues_reminder BOOLEAN DEFAULT true,
+ email_payment_confirmation BOOLEAN DEFAULT true,
+ email_membership_updates BOOLEAN DEFAULT true,
+
+ -- General notifications
+ email_announcements BOOLEAN DEFAULT true,
+ email_newsletter BOOLEAN DEFAULT true,
+
+ -- Newsletter frequency (if subscribed)
+ newsletter_frequency TEXT DEFAULT 'monthly' CHECK (newsletter_frequency IN ('weekly', 'monthly', 'quarterly', 'never')),
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Create indexes
+CREATE INDEX IF NOT EXISTS idx_notification_prefs_member ON user_notification_preferences(member_id);
+
+-- Enable RLS
+ALTER TABLE user_notification_preferences ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies
+-- Members can view their own preferences
+CREATE POLICY "Members can view own notification preferences"
+ON user_notification_preferences FOR SELECT
+USING (member_id = auth.uid());
+
+-- Members can insert their own preferences
+CREATE POLICY "Members can insert own notification preferences"
+ON user_notification_preferences FOR INSERT
+WITH CHECK (member_id = auth.uid());
+
+-- Members can update their own preferences
+CREATE POLICY "Members can update own notification preferences"
+ON user_notification_preferences FOR UPDATE
+USING (member_id = auth.uid())
+WITH CHECK (member_id = auth.uid());
+
+-- Admins can view all preferences (for admin reports)
+CREATE POLICY "Admins can view all notification preferences"
+ON user_notification_preferences FOR SELECT
+USING (
+ EXISTS (
+ SELECT 1 FROM members
+ WHERE members.id = auth.uid()
+ AND members.role = 'admin'
+ )
+);
+
+-- Function to create default preferences for new members
+CREATE OR REPLACE FUNCTION create_default_notification_preferences()
+RETURNS TRIGGER AS $$
+BEGIN
+ INSERT INTO user_notification_preferences (member_id)
+ VALUES (NEW.id)
+ ON CONFLICT (member_id) DO NOTHING;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Trigger to create preferences when a new member is created
+DROP TRIGGER IF EXISTS on_member_created_create_notification_prefs ON members;
+CREATE TRIGGER on_member_created_create_notification_prefs
+ AFTER INSERT ON members
+ FOR EACH ROW
+ EXECUTE FUNCTION create_default_notification_preferences();
+
+-- Create default preferences for existing members
+INSERT INTO user_notification_preferences (member_id)
+SELECT id FROM members
+ON CONFLICT (member_id) DO NOTHING;
+
+-- Add updated_at trigger
+CREATE OR REPLACE FUNCTION update_notification_prefs_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS set_notification_prefs_updated_at ON user_notification_preferences;
+CREATE TRIGGER set_notification_prefs_updated_at
+ BEFORE UPDATE ON user_notification_preferences
+ FOR EACH ROW
+ EXECUTE FUNCTION update_notification_prefs_updated_at();
diff --git a/supabase/migrations/005_fix_avatars_storage_policy.sql b/supabase/migrations/005_fix_avatars_storage_policy.sql
new file mode 100644
index 0000000..b05afb2
--- /dev/null
+++ b/supabase/migrations/005_fix_avatars_storage_policy.sql
@@ -0,0 +1,37 @@
+-- Monaco USA Portal 2026
+-- Migration 005: Fix Avatars Storage Policy
+-- ================================================
+-- This fixes the RLS policy for avatars bucket to allow authenticated users to upload
+
+-- Drop existing restrictive policies
+DROP POLICY IF EXISTS "avatars_insert_policy" ON storage.objects;
+DROP POLICY IF EXISTS "avatars_update_policy" ON storage.objects;
+DROP POLICY IF EXISTS "avatars_delete_policy" ON storage.objects;
+
+-- Create new permissive policy for authenticated users
+-- Avatars bucket is public for reading, so we just need to ensure authenticated users can upload
+CREATE POLICY "avatars_insert_policy" ON storage.objects FOR INSERT
+TO authenticated
+WITH CHECK (bucket_id = 'avatars');
+
+CREATE POLICY "avatars_update_policy" ON storage.objects FOR UPDATE
+TO authenticated
+USING (bucket_id = 'avatars');
+
+CREATE POLICY "avatars_delete_policy" ON storage.objects FOR DELETE
+TO authenticated
+USING (bucket_id = 'avatars');
+
+-- Ensure the avatars bucket exists and is public
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+ 'avatars',
+ 'avatars',
+ true,
+ 5242880, -- 5MB
+ ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif']
+)
+ON CONFLICT (id) DO UPDATE SET
+ public = true,
+ file_size_limit = EXCLUDED.file_size_limit,
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
diff --git a/supabase/migrations/006_document_folders.sql b/supabase/migrations/006_document_folders.sql
new file mode 100644
index 0000000..93d22db
--- /dev/null
+++ b/supabase/migrations/006_document_folders.sql
@@ -0,0 +1,100 @@
+-- Monaco USA Portal 2026 - Document Folders
+-- Adds hierarchical folder support for document organization
+
+-- ============================================
+-- DOCUMENT FOLDERS TABLE
+-- ============================================
+CREATE TABLE public.document_folders (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL,
+ parent_id UUID REFERENCES public.document_folders(id) ON DELETE CASCADE,
+ path TEXT, -- Full path for breadcrumb support
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+ created_by UUID REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ -- Ensure unique folder names within same parent
+ UNIQUE(name, parent_id)
+);
+
+-- Add updated_at trigger
+CREATE TRIGGER document_folders_updated_at
+ BEFORE UPDATE ON public.document_folders
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- ============================================
+-- ADD FOLDER_ID TO DOCUMENTS TABLE
+-- ============================================
+ALTER TABLE public.documents
+ADD COLUMN folder_id UUID REFERENCES public.document_folders(id) ON DELETE SET NULL;
+
+-- ============================================
+-- PATH UPDATE TRIGGER
+-- ============================================
+CREATE OR REPLACE FUNCTION update_folder_path()
+RETURNS TRIGGER AS $$
+DECLARE
+ parent_path TEXT;
+BEGIN
+ IF NEW.parent_id IS NULL THEN
+ NEW.path = NEW.name;
+ ELSE
+ SELECT path INTO parent_path
+ FROM public.document_folders
+ WHERE id = NEW.parent_id;
+
+ NEW.path = parent_path || '/' || NEW.name;
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER folder_path_trigger
+ BEFORE INSERT OR UPDATE ON public.document_folders
+ FOR EACH ROW
+ EXECUTE FUNCTION update_folder_path();
+
+-- ============================================
+-- RLS POLICIES FOR FOLDERS
+-- ============================================
+ALTER TABLE public.document_folders ENABLE ROW LEVEL SECURITY;
+
+-- Everyone can view folders based on visibility
+CREATE POLICY "Folders visible based on visibility" ON public.document_folders
+ FOR SELECT USING (
+ visibility = 'public' OR
+ (visibility = 'members' AND auth.uid() IS NOT NULL) OR
+ (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ )) OR
+ (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ );
+
+-- Board and admin can create folders
+CREATE POLICY "Board/admin can create folders" ON public.document_folders
+ FOR INSERT WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- Board and admin can update folders
+CREATE POLICY "Board/admin can update folders" ON public.document_folders
+ FOR UPDATE USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- Only admin can delete folders
+CREATE POLICY "Admin can delete folders" ON public.document_folders
+ FOR DELETE USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+-- ============================================
+-- INDEX FOR FOLDER QUERIES
+-- ============================================
+CREATE INDEX idx_document_folders_parent ON public.document_folders(parent_id);
+CREATE INDEX idx_documents_folder ON public.documents(folder_id);
diff --git a/supabase/migrations/007_dues_reminders.sql b/supabase/migrations/007_dues_reminders.sql
new file mode 100644
index 0000000..21d58f0
--- /dev/null
+++ b/supabase/migrations/007_dues_reminders.sql
@@ -0,0 +1,307 @@
+-- Monaco USA Portal 2026 - Dues Reminders Enhancement
+-- Track sent reminders to avoid duplicates and enable analytics
+
+-- ============================================
+-- DUES REMINDER LOGS TABLE
+-- ============================================
+CREATE TABLE public.dues_reminder_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ reminder_type TEXT NOT NULL CHECK (reminder_type IN ('due_soon_30', 'due_soon_7', 'due_soon_1', 'overdue', 'grace_period', 'inactive_notice')),
+ due_date DATE NOT NULL,
+ sent_at TIMESTAMPTZ DEFAULT NOW(),
+ email_log_id UUID REFERENCES public.email_logs(id),
+ -- Prevent duplicate reminders for same member/type/period
+ UNIQUE(member_id, reminder_type, due_date)
+);
+
+-- Enable RLS
+ALTER TABLE public.dues_reminder_logs ENABLE ROW LEVEL SECURITY;
+
+-- Board and admin can view reminder logs
+CREATE POLICY "Board/admin can view reminder logs" ON public.dues_reminder_logs
+ FOR SELECT USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- Only service role can insert reminder logs (from cron/server)
+CREATE POLICY "Service role can manage reminder logs" ON public.dues_reminder_logs
+ FOR ALL USING (true)
+ WITH CHECK (true);
+
+-- Index for fast lookups
+CREATE INDEX idx_reminder_logs_member_date ON public.dues_reminder_logs(member_id, due_date);
+CREATE INDEX idx_reminder_logs_type_sent ON public.dues_reminder_logs(reminder_type, sent_at);
+
+-- ============================================
+-- ADD EMAIL TEMPLATES FOR DUES REMINDERS
+-- ============================================
+
+-- 30 days before due reminder
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'dues_reminder_30',
+ 'Dues Reminder - 30 Days',
+ 'payment',
+ 'Your Monaco USA Membership Dues Are Coming Up',
+ 'Dear {{first_name}},
+This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}} .
+
+
Payment Details:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+You can also view your payment status and history in the member portal:
+
+ View My Account
+
+Thank you for being a valued member of Monaco USA!
',
+ 'Dear {{first_name}},
+
+This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}}.
+
+Payment Details:
+- Amount Due: {{amount}}
+- Due Date: {{due_date}}
+- Member ID: {{member_id}}
+
+Bank Transfer Details:
+- Account Holder: {{account_holder}}
+- Bank: {{bank_name}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+Visit the member portal to view your payment status: {{portal_url}}
+
+Thank you for being a valued member of Monaco USA!',
+ true,
+ true,
+ '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID for reference", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}'
+),
+(
+ 'dues_reminder_7',
+ 'Dues Reminder - 7 Days',
+ 'payment',
+ 'Reminder: Monaco USA Dues Due in 7 Days',
+ 'Dear {{first_name}},
+Your Monaco USA membership dues will be due in 7 days on {{due_date}}.
+
+
Payment Information:
+
Amount: {{amount}}
+
Due Date: {{due_date}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
+Questions? Contact us at contact@monacousa.org
',
+ 'Dear {{first_name}},
+
+Your Monaco USA membership dues will be due in 7 days on {{due_date}}.
+
+Amount: {{amount}}
+IBAN: {{iban}}
+Reference: {{member_id}}
+
+Visit the portal to pay: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID", "iban": "IBAN number", "portal_url": "Portal URL"}'
+),
+(
+ 'dues_reminder_1',
+ 'Dues Reminder - 1 Day',
+ 'payment',
+ 'URGENT: Monaco USA Dues Due Tomorrow',
+ 'Dear {{first_name}},
+Your Monaco USA membership dues are due tomorrow ({{due_date}}).
+
+
Payment Required:
+
Amount: {{amount}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+To maintain your active membership status and continued access to member benefits, please ensure payment is made by the due date.
+
+ Pay Now
+
',
+ 'Dear {{first_name}},
+
+URGENT: Your Monaco USA membership dues are due tomorrow ({{due_date}}).
+
+Amount: {{amount}}
+IBAN: {{iban}}
+Reference: {{member_id}}
+
+To maintain your active membership, please pay by the due date.
+
+Pay now: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "due_date": "Dues due date", "amount": "Amount due", "member_id": "Member ID", "iban": "IBAN number", "portal_url": "Portal URL"}'
+),
+(
+ 'dues_overdue',
+ 'Dues Overdue Notice',
+ 'payment',
+ 'ACTION REQUIRED: Monaco USA Dues Are Now Overdue',
+ 'Dear {{first_name}},
+Your Monaco USA membership dues are now {{days_overdue}} days overdue .
+
+
Overdue Payment:
+
Amount: {{amount}}
+
Original Due Date: {{due_date}}
+
Days Overdue: {{days_overdue}}
+
+
+
Grace Period: You have {{grace_days_remaining}} days remaining in your grace period. After this, your membership status will be changed to inactive.
+
+Please remit payment as soon as possible to maintain your membership benefits.
+
+
Payment Details:
+
Account: {{account_holder}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
',
+ 'Dear {{first_name}},
+
+Your Monaco USA membership dues are now {{days_overdue}} days overdue.
+
+Amount: {{amount}}
+Original Due Date: {{due_date}}
+Days Overdue: {{days_overdue}}
+
+Grace Period: You have {{grace_days_remaining}} days remaining. After this, your membership will be marked inactive.
+
+Payment Details:
+- Account: {{account_holder}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+Pay now: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "due_date": "Original due date", "amount": "Amount due", "days_overdue": "Number of days overdue", "grace_days_remaining": "Days left in grace period", "member_id": "Member ID", "account_holder": "Account holder", "iban": "IBAN", "portal_url": "Portal URL"}'
+),
+(
+ 'dues_grace_warning',
+ 'Grace Period Ending Warning',
+ 'payment',
+ 'WARNING: Monaco USA Grace Period Ending Soon',
+ 'Dear {{first_name}},
+Your grace period ends in {{grace_days_remaining}} days.
+Your membership dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue.
+
+
If payment is not received by {{grace_end_date}}, your membership status will automatically change to INACTIVE and you will lose access to member benefits.
+
+Please make your payment immediately to avoid interruption:
+
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now - Urgent
+
',
+ 'Dear {{first_name}},
+
+WARNING: Your grace period ends in {{grace_days_remaining}} days.
+
+Your dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue.
+
+If payment is not received by {{grace_end_date}}, your membership will become INACTIVE.
+
+IBAN: {{iban}}
+Reference: {{member_id}}
+
+Pay now: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "due_date": "Original due date", "amount": "Amount due", "days_overdue": "Days overdue", "grace_days_remaining": "Days until grace period ends", "grace_end_date": "Date grace period ends", "member_id": "Member ID", "iban": "IBAN", "portal_url": "Portal URL"}'
+),
+(
+ 'dues_inactive_notice',
+ 'Membership Marked Inactive',
+ 'payment',
+ 'Notice: Your Monaco USA Membership Is Now Inactive',
+ 'Dear {{first_name}},
+Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE .
+
+
Status Change:
+
Previous Status: Active
+
New Status: Inactive
+
Outstanding Amount: {{amount}}
+
+As an inactive member, you will no longer have access to:
+
+ Member-only events
+ Member directory
+ Member communications
+ Voting rights
+
+To reactivate your membership, please pay your outstanding dues:
+
+
Account: {{account_holder}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Reactivate My Membership
+
+If you believe this is an error or have questions, please contact us at contact@monacousa.org
',
+ 'Dear {{first_name}},
+
+Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE.
+
+Outstanding Amount: {{amount}}
+
+As an inactive member, you no longer have access to member-only events, directory, communications, or voting rights.
+
+To reactivate, please pay your dues:
+- Account: {{account_holder}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+Reactivate: {{portal_url}}
+
+Questions? Contact contact@monacousa.org',
+ true,
+ true,
+ '{"first_name": "Member first name", "amount": "Outstanding amount", "member_id": "Member ID", "account_holder": "Account holder", "iban": "IBAN", "portal_url": "Portal URL"}'
+)
+ON CONFLICT (template_key) DO NOTHING;
+
+-- ============================================
+-- HELPER FUNCTION: Get dues settings
+-- ============================================
+CREATE OR REPLACE FUNCTION get_dues_settings()
+RETURNS TABLE (
+ reminder_days_before INTEGER[],
+ grace_period_days INTEGER,
+ auto_inactive_enabled BOOLEAN,
+ payment_iban TEXT,
+ payment_account_holder TEXT,
+ payment_bank_name TEXT
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ COALESCE((SELECT (setting_value)::INTEGER[] FROM app_settings WHERE category = 'dues' AND setting_key = 'reminder_days_before'), ARRAY[30, 7, 1])::INTEGER[],
+ COALESCE((SELECT (setting_value)::INTEGER FROM app_settings WHERE category = 'dues' AND setting_key = 'grace_period_days'), 30)::INTEGER,
+ COALESCE((SELECT (setting_value)::BOOLEAN FROM app_settings WHERE category = 'dues' AND setting_key = 'auto_inactive_enabled'), true)::BOOLEAN,
+ COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_iban'), '')::TEXT,
+ COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_account_holder'), '')::TEXT,
+ COALESCE((SELECT setting_value::TEXT FROM app_settings WHERE category = 'dues' AND setting_key = 'payment_bank_name'), '')::TEXT;
+END;
+$$ LANGUAGE plpgsql STABLE;
diff --git a/supabase/migrations/008_s3_public_endpoint.sql b/supabase/migrations/008_s3_public_endpoint.sql
new file mode 100644
index 0000000..c9489e0
--- /dev/null
+++ b/supabase/migrations/008_s3_public_endpoint.sql
@@ -0,0 +1,11 @@
+-- ============================================
+-- S3 PUBLIC ENDPOINT SETTING
+-- Separate URL for browser-accessible S3 files
+-- ============================================
+
+-- Add S3 public endpoint setting for browser access
+-- The regular s3_endpoint is for server-to-S3 communication (internal Docker)
+-- The s3_public_endpoint is for browser access to files (external URL)
+INSERT INTO public.app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
+ ('storage', 's3_public_endpoint', '""', 'text', 'Public Endpoint URL', 'Browser-accessible S3 URL (e.g., http://localhost:9000). Leave empty to use the same as S3 Endpoint.', false)
+ON CONFLICT (category, setting_key) DO NOTHING;
diff --git a/supabase/migrations/009_dual_avatar_urls.sql b/supabase/migrations/009_dual_avatar_urls.sql
new file mode 100644
index 0000000..10a2f42
--- /dev/null
+++ b/supabase/migrations/009_dual_avatar_urls.sql
@@ -0,0 +1,22 @@
+-- ============================================
+-- DUAL AVATAR URL COLUMNS
+-- Separate columns for S3 and local storage URLs
+-- ============================================
+
+-- Add separate columns for S3 and local (Supabase Storage) avatar URLs
+-- This allows switching between storage backends without losing URLs
+
+-- Add local avatar URL column
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_local TEXT;
+
+-- Add S3 avatar URL column
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_url_s3 TEXT;
+
+-- Add avatar storage path column (for deletion purposes)
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS avatar_path TEXT;
+
+-- Comment explaining the columns
+COMMENT ON COLUMN public.members.avatar_url IS 'Current active avatar URL (computed based on storage setting)';
+COMMENT ON COLUMN public.members.avatar_url_local IS 'Avatar URL when stored in Supabase Storage';
+COMMENT ON COLUMN public.members.avatar_url_s3 IS 'Avatar URL when stored in S3/MinIO';
+COMMENT ON COLUMN public.members.avatar_path IS 'Storage path for avatar file (e.g., member_id/avatar.jpg)';
diff --git a/supabase/migrations/010_storage_service_role_policies.sql b/supabase/migrations/010_storage_service_role_policies.sql
new file mode 100644
index 0000000..b251fcd
--- /dev/null
+++ b/supabase/migrations/010_storage_service_role_policies.sql
@@ -0,0 +1,79 @@
+-- ============================================
+-- STORAGE SERVICE ROLE POLICIES
+-- Allow service_role to perform all operations on avatars bucket
+-- This fixes RLS issues when using supabaseAdmin for storage operations
+-- ============================================
+
+-- First, drop any existing service role policies (in case they exist with different names)
+DROP POLICY IF EXISTS "Service role can insert avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can update avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can delete avatars" ON storage.objects;
+DROP POLICY IF EXISTS "Service role can read avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_insert_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_update_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_delete_avatars" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_select_avatars" ON storage.objects;
+
+-- Service role INSERT policy for avatars
+CREATE POLICY "service_role_insert_avatars" ON storage.objects
+FOR INSERT TO service_role
+WITH CHECK (bucket_id = 'avatars');
+
+-- Service role UPDATE policy for avatars
+CREATE POLICY "service_role_update_avatars" ON storage.objects
+FOR UPDATE TO service_role
+USING (bucket_id = 'avatars');
+
+-- Service role DELETE policy for avatars
+CREATE POLICY "service_role_delete_avatars" ON storage.objects
+FOR DELETE TO service_role
+USING (bucket_id = 'avatars');
+
+-- Service role SELECT policy for avatars
+CREATE POLICY "service_role_select_avatars" ON storage.objects
+FOR SELECT TO service_role
+USING (bucket_id = 'avatars');
+
+-- Also add service_role policies for documents bucket
+DROP POLICY IF EXISTS "service_role_insert_documents" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_update_documents" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_delete_documents" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_select_documents" ON storage.objects;
+
+CREATE POLICY "service_role_insert_documents" ON storage.objects
+FOR INSERT TO service_role
+WITH CHECK (bucket_id = 'documents');
+
+CREATE POLICY "service_role_update_documents" ON storage.objects
+FOR UPDATE TO service_role
+USING (bucket_id = 'documents');
+
+CREATE POLICY "service_role_delete_documents" ON storage.objects
+FOR DELETE TO service_role
+USING (bucket_id = 'documents');
+
+CREATE POLICY "service_role_select_documents" ON storage.objects
+FOR SELECT TO service_role
+USING (bucket_id = 'documents');
+
+-- Also add service_role policies for event-images bucket
+DROP POLICY IF EXISTS "service_role_insert_event_images" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_update_event_images" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_delete_event_images" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_select_event_images" ON storage.objects;
+
+CREATE POLICY "service_role_insert_event_images" ON storage.objects
+FOR INSERT TO service_role
+WITH CHECK (bucket_id = 'event-images');
+
+CREATE POLICY "service_role_update_event_images" ON storage.objects
+FOR UPDATE TO service_role
+USING (bucket_id = 'event-images');
+
+CREATE POLICY "service_role_delete_event_images" ON storage.objects
+FOR DELETE TO service_role
+USING (bucket_id = 'event-images');
+
+CREATE POLICY "service_role_select_event_images" ON storage.objects
+FOR SELECT TO service_role
+USING (bucket_id = 'event-images');
diff --git a/supabase/migrations/011_fix_service_role_rls.sql b/supabase/migrations/011_fix_service_role_rls.sql
new file mode 100644
index 0000000..7adb131
--- /dev/null
+++ b/supabase/migrations/011_fix_service_role_rls.sql
@@ -0,0 +1,98 @@
+-- ============================================
+-- FIX SERVICE ROLE RLS BYPASS
+-- Ensure service_role can properly bypass RLS for storage operations
+-- ============================================
+
+-- The service_role should have BYPASSRLS attribute in Supabase
+-- But in self-hosted setups, this might not be configured correctly
+-- This migration ensures proper access through multiple approaches
+
+-- Approach 1: Grant service_role BYPASSRLS (if not already set)
+-- Note: This requires superuser privileges, which the migration might not have
+-- If this fails, the explicit policies below will still work
+DO $$
+BEGIN
+ -- Check if service_role exists and doesn't have bypassrls
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role' AND NOT rolbypassrls) THEN
+ ALTER ROLE service_role BYPASSRLS;
+ RAISE NOTICE 'Granted BYPASSRLS to service_role';
+ ELSE
+ RAISE NOTICE 'service_role already has BYPASSRLS or does not exist';
+ END IF;
+EXCEPTION
+ WHEN insufficient_privilege THEN
+ RAISE NOTICE 'Could not grant BYPASSRLS (insufficient privileges) - using explicit policies instead';
+ WHEN OTHERS THEN
+ RAISE NOTICE 'Error granting BYPASSRLS: % - using explicit policies instead', SQLERRM;
+END $$;
+
+-- Approach 2: Ensure RLS is properly configured on storage.objects
+-- Check if RLS is enabled and ensure our policies exist
+DO $$
+BEGIN
+ -- Ensure RLS is enabled on storage.objects (it should be by default)
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_tables
+ WHERE schemaname = 'storage' AND tablename = 'objects' AND rowsecurity = true
+ ) THEN
+ ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;
+ RAISE NOTICE 'Enabled RLS on storage.objects';
+ END IF;
+END $$;
+
+-- Approach 3: Create permissive policies for service_role on ALL storage buckets
+-- These use a single policy per operation type that covers all buckets
+
+-- First, clean up any existing service_role policies
+DROP POLICY IF EXISTS "service_role_all_select" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_insert" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_update" ON storage.objects;
+DROP POLICY IF EXISTS "service_role_all_delete" ON storage.objects;
+
+-- Create universal service_role policies (allow access to ALL buckets)
+CREATE POLICY "service_role_all_select" ON storage.objects
+FOR SELECT TO service_role
+USING (true);
+
+CREATE POLICY "service_role_all_insert" ON storage.objects
+FOR INSERT TO service_role
+WITH CHECK (true);
+
+CREATE POLICY "service_role_all_update" ON storage.objects
+FOR UPDATE TO service_role
+USING (true);
+
+CREATE POLICY "service_role_all_delete" ON storage.objects
+FOR DELETE TO service_role
+USING (true);
+
+-- Approach 4: Grant necessary table permissions to service_role
+GRANT ALL ON storage.objects TO service_role;
+GRANT ALL ON storage.buckets TO service_role;
+GRANT USAGE ON SCHEMA storage TO service_role;
+
+-- Also ensure service_role can use sequences in storage schema
+DO $$
+DECLARE
+ seq_name text;
+BEGIN
+ FOR seq_name IN
+ SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'storage'
+ LOOP
+ EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE storage.%I TO service_role', seq_name);
+ END LOOP;
+END $$;
+
+-- Verify the setup
+DO $$
+DECLARE
+ policy_count int;
+BEGIN
+ SELECT COUNT(*) INTO policy_count
+ FROM pg_policies
+ WHERE schemaname = 'storage'
+ AND tablename = 'objects'
+ AND roles @> ARRAY['service_role']::name[];
+
+ RAISE NOTICE 'service_role has % policies on storage.objects', policy_count;
+END $$;
diff --git a/supabase/migrations/012_dual_document_urls.sql b/supabase/migrations/012_dual_document_urls.sql
new file mode 100644
index 0000000..a0d7628
--- /dev/null
+++ b/supabase/migrations/012_dual_document_urls.sql
@@ -0,0 +1,49 @@
+-- ============================================
+-- DUAL STORAGE SUPPORT FOR DOCUMENTS
+-- Store URLs for both Supabase Storage and S3 backends
+-- Mirrors the avatar dual-storage pattern from migration 009
+-- ============================================
+
+-- Add local storage URL column
+ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_local TEXT;
+
+-- Add S3 storage URL column
+ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS file_url_s3 TEXT;
+
+-- Add storage path column (relative path used for both backends)
+ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS storage_path TEXT;
+
+-- Add comments for documentation
+COMMENT ON COLUMN public.documents.file_path IS 'Current active file URL (computed based on storage setting) - kept for backwards compatibility';
+COMMENT ON COLUMN public.documents.file_url_local IS 'File URL when stored in Supabase Storage';
+COMMENT ON COLUMN public.documents.file_url_s3 IS 'File URL when stored in S3/MinIO';
+COMMENT ON COLUMN public.documents.storage_path IS 'Storage path for file (e.g., timestamp-random-filename.pdf)';
+
+-- Migrate existing file_path values to storage_path and file_url_local
+-- This handles documents uploaded before dual-storage was implemented
+UPDATE public.documents
+SET
+ storage_path = CASE
+ WHEN file_path LIKE 'http%' THEN
+ -- Extract filename from URL
+ CASE
+ WHEN file_path LIKE '%/storage/v1/object/public/documents/%' THEN
+ substring(file_path from '/storage/v1/object/public/documents/([^?]+)')
+ WHEN file_path LIKE '%/documents/%' THEN
+ substring(file_path from '/documents/([^?]+)')
+ ELSE file_path
+ END
+ ELSE file_path
+ END,
+ file_url_local = CASE
+ WHEN file_path LIKE 'http%' AND file_path LIKE '%/storage/v1/object/public/documents/%' THEN file_path
+ ELSE NULL
+ END,
+ file_url_s3 = CASE
+ WHEN file_path LIKE 'http%' AND file_path NOT LIKE '%/storage/v1/object/public/documents/%' THEN file_path
+ ELSE NULL
+ END
+WHERE storage_path IS NULL;
+
+-- Create index for faster lookups
+CREATE INDEX IF NOT EXISTS idx_documents_storage_path ON public.documents(storage_path);
diff --git a/supabase/migrations/013_email_background_images.sql b/supabase/migrations/013_email_background_images.sql
new file mode 100644
index 0000000..b07ac77
--- /dev/null
+++ b/supabase/migrations/013_email_background_images.sql
@@ -0,0 +1,671 @@
+-- ============================================
+-- Migration 013: Update Email Templates with Background Image
+-- Adds S3-hosted Monaco background image to all email templates
+-- Matches login screen styling: image + gradient overlay
+-- ============================================
+
+-- Background image URL
+-- Using: https://s3.monacousa.org/public/monaco_high_res.jpg
+-- Gradient overlay: from-slate-900/80 via-slate-900/60 to-monaco-900/70
+
+-- =====================
+-- Update Welcome Email Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Welcome to Monaco USA!
+ Dear {{first_name}},
+ We are thrilled to welcome you to the Monaco USA community! Your membership has been created and you can now access all member features.
+ To get started:
+
+ Set up your password using the separate email we sent
+ Complete your profile with your details
+ Explore upcoming events and connect with fellow members
+
+ If you have any questions, please don''t hesitate to reach out to our board members.
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'welcome';
+
+-- =====================
+-- Update RSVP Confirmation Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ RSVP Confirmed!
+ Dear {{first_name}},
+ Your RSVP has been confirmed for the following event:
+
+
{{event_title}}
+
Date: {{event_date}}
+
Location: {{event_location}}
+
Guests: {{guest_count}}
+
+ We look forward to seeing you there!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'rsvp_confirmation';
+
+-- =====================
+-- Update Payment Received Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Payment Received
+ Dear {{first_name}},
+ Thank you! We have received your membership dues payment.
+
+
Payment Details:
+
Amount: {{amount}}
+
Payment Date: {{payment_date}}
+
Period: {{period_start}} - {{period_end}}
+
Member ID: {{member_id}}
+
+ Your membership is now active through {{period_end}}. Thank you for your continued support of Monaco USA!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'payment_received';
+
+-- =====================
+-- Update Waitlist Promotion Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Great News!
+ Dear {{first_name}},
+ A spot has opened up! You have been promoted from the waitlist for:
+
+
{{event_title}}
+
Date: {{event_date}}
+
Location: {{event_location}}
+
+ Your attendance is now confirmed. We look forward to seeing you!
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'waitlist_promotion';
+
+-- =====================
+-- Update Dues Reminder 30 Days Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Dues Reminder
+ Dear {{first_name}},
+ This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}} .
+
+
Payment Details:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+ You can also view your payment status and history in the member portal:
+
+ View My Account
+
+ Thank you for being a valued member of Monaco USA!
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'dues_reminder_30';
+
+-- =====================
+-- Update Dues Reminder 7 Days Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Dues Due Soon
+ Dear {{first_name}},
+ Your Monaco USA membership dues are due in 7 days on {{due_date}}.
+
+
Payment Details:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
+ Thank you for your continued support!
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'dues_reminder_7';
+
+-- =====================
+-- Update Dues Reminder 1 Day Template
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Final Reminder
+ Dear {{first_name}},
+ This is a final reminder that your Monaco USA membership dues are due tomorrow ({{due_date}}).
+
+
Urgent - Payment Required:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
+ Please disregard this email if you have already made your payment.
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'dues_reminder_1';
+
+-- =====================
+-- Update Dues Reminder (Generic) Template if exists
+-- =====================
+UPDATE public.email_templates
+SET body_html = '
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Membership Dues Reminder
+ Dear {{first_name}},
+ This is a reminder about your Monaco USA membership dues.
+
+
Payment Details:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+ View My Account
+
+ Thank you for being a valued member of Monaco USA!
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+'
+WHERE template_key = 'dues_reminder';
diff --git a/supabase/migrations/014_event_reminders.sql b/supabase/migrations/014_event_reminders.sql
new file mode 100644
index 0000000..ba3fc87
--- /dev/null
+++ b/supabase/migrations/014_event_reminders.sql
@@ -0,0 +1,133 @@
+-- Monaco USA Portal 2026 - Event Reminder Emails
+-- Automated reminders sent 24 hours before events to RSVPed members
+
+-- ============================================
+-- EVENT REMINDER LOGS TABLE
+-- ============================================
+CREATE TABLE public.event_reminder_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ rsvp_id UUID NOT NULL REFERENCES public.event_rsvps(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+ reminder_type TEXT NOT NULL DEFAULT '24hr' CHECK (reminder_type IN ('24hr', '1hr', 'day_of')),
+ sent_at TIMESTAMPTZ DEFAULT NOW(),
+ email_log_id UUID REFERENCES public.email_logs(id),
+ -- Prevent duplicate reminders for same event/member/type
+ UNIQUE(event_id, member_id, reminder_type)
+);
+
+-- Enable RLS
+ALTER TABLE public.event_reminder_logs ENABLE ROW LEVEL SECURITY;
+
+-- Board and admin can view reminder logs
+CREATE POLICY "Board/admin can view event reminder logs" ON public.event_reminder_logs
+ FOR SELECT USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+-- Service role can manage reminder logs (from cron/server)
+CREATE POLICY "Service role can manage event reminder logs" ON public.event_reminder_logs
+ FOR ALL USING (true)
+ WITH CHECK (true);
+
+-- Indexes for fast lookups
+CREATE INDEX idx_event_reminder_logs_event ON public.event_reminder_logs(event_id);
+CREATE INDEX idx_event_reminder_logs_member ON public.event_reminder_logs(member_id);
+CREATE INDEX idx_event_reminder_logs_sent ON public.event_reminder_logs(sent_at);
+
+-- ============================================
+-- ADD APP SETTINGS FOR EVENT REMINDERS
+-- ============================================
+INSERT INTO public.app_settings (category, setting_key, setting_value, display_name, description, is_public)
+VALUES
+ ('events', 'event_reminders_enabled', 'true', 'Event Reminders Enabled', 'Enable automated event reminder emails', false),
+ ('events', 'event_reminder_hours_before', '24', 'Reminder Hours Before', 'Hours before event to send reminder', false)
+ON CONFLICT (category, setting_key) DO NOTHING;
+
+-- ============================================
+-- ADD EMAIL TEMPLATE FOR EVENT REMINDER
+-- ============================================
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'event_reminder_24hr',
+ 'Event Reminder - 24 Hours',
+ 'events',
+ 'Reminder: {{event_title}} is Tomorrow!',
+ 'Hi {{first_name}},
+This is a friendly reminder that {{event_title}} is happening tomorrow!
+
+
Event Details:
+
Date: {{event_date}}
+
Time: {{event_time}}
+
Location: {{event_location}}
+ {{#if guest_count}}
+
You''re bringing {{guest_count}} guest(s)
+ {{/if}}
+
+We look forward to seeing you there!
+
+ View Event Details
+
+Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.
',
+ 'Hi {{first_name}},
+
+This is a friendly reminder that {{event_title}} is happening tomorrow!
+
+Event Details:
+- Date: {{event_date}}
+- Time: {{event_time}}
+- Location: {{event_location}}
+{{#if guest_count}}
+- You''re bringing {{guest_count}} guest(s)
+{{/if}}
+
+We look forward to seeing you there!
+
+View event: {{portal_url}}
+
+Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.',
+ true,
+ true,
+ '{"first_name": "Member first name", "event_title": "Event title", "event_date": "Event date", "event_time": "Event start time", "event_location": "Event location", "guest_count": "Number of guests", "portal_url": "Event URL in portal"}'
+)
+ON CONFLICT (template_key) DO NOTHING;
+
+-- ============================================
+-- VIEW: Events needing reminders
+-- ============================================
+CREATE OR REPLACE VIEW public.events_needing_reminders AS
+SELECT
+ e.id AS event_id,
+ e.title AS event_title,
+ e.start_datetime,
+ e.end_datetime,
+ e.location,
+ e.timezone,
+ r.id AS rsvp_id,
+ r.member_id,
+ r.guest_count,
+ r.status AS rsvp_status,
+ m.first_name,
+ m.last_name,
+ m.email
+FROM public.events e
+JOIN public.event_rsvps r ON r.event_id = e.id
+JOIN public.members m ON m.id = r.member_id
+WHERE
+ -- Event is published
+ e.status = 'published'
+ -- Event starts within 24-25 hours from now (hourly cron window)
+ AND e.start_datetime > NOW()
+ AND e.start_datetime <= NOW() + INTERVAL '25 hours'
+ AND e.start_datetime > NOW() + INTERVAL '23 hours'
+ -- Member has confirmed RSVP
+ AND r.status = 'confirmed'
+ -- No reminder already sent for this event/member
+ AND NOT EXISTS (
+ SELECT 1 FROM public.event_reminder_logs erl
+ WHERE erl.event_id = e.id
+ AND erl.member_id = r.member_id
+ AND erl.reminder_type = '24hr'
+ )
+ -- Member has email
+ AND m.email IS NOT NULL;
diff --git a/supabase/migrations/015_fix_email_template_styling.sql b/supabase/migrations/015_fix_email_template_styling.sql
new file mode 100644
index 0000000..88ae8b8
--- /dev/null
+++ b/supabase/migrations/015_fix_email_template_styling.sql
@@ -0,0 +1,192 @@
+-- Monaco USA Portal 2026 - Fix Email Template Styling
+-- Update all email templates with proper text centering and styling
+
+-- ============================================
+-- UPDATE DUES REMINDER TEMPLATES
+-- ============================================
+
+-- 30 days before due reminder
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+This is a friendly reminder that your Monaco USA membership dues will be due on {{due_date}} .
+
+
Payment Details:
+
Amount Due: {{amount}}
+
Due Date: {{due_date}}
+
Member ID: {{member_id}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+You can also view your payment status and history in the member portal:
+
+ View My Account
+
+Thank you for being a valued member of Monaco USA!
'
+WHERE template_key = 'dues_reminder_30';
+
+-- 7 days before due reminder
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Your Monaco USA membership dues will be due in 7 days on {{due_date}}.
+
+
Payment Information:
+
Amount: {{amount}}
+
Due Date: {{due_date}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
+Questions? Contact us at contact@monacousa.org
'
+WHERE template_key = 'dues_reminder_7';
+
+-- 1 day before due reminder
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Your Monaco USA membership dues are due tomorrow ({{due_date}}).
+
+
Payment Required:
+
Amount: {{amount}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+To maintain your active membership status and continued access to member benefits, please ensure payment is made by the due date.
+
+ Pay Now
+
'
+WHERE template_key = 'dues_reminder_1';
+
+-- Overdue notice
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Your Monaco USA membership dues are now {{days_overdue}} days overdue .
+
+
Overdue Payment:
+
Amount: {{amount}}
+
Original Due Date: {{due_date}}
+
Days Overdue: {{days_overdue}}
+
+
+
Grace Period: You have {{grace_days_remaining}} days remaining in your grace period. After this, your membership status will be changed to inactive.
+
+Please remit payment as soon as possible to maintain your membership benefits.
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now
+
'
+WHERE template_key = 'dues_overdue';
+
+-- Grace period warning
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Your grace period ends in {{grace_days_remaining}} days.
+Your membership dues of {{amount}} were due on {{due_date}} and are now {{days_overdue}} days overdue.
+
+
If payment is not received by {{grace_end_date}}, your membership status will automatically change to INACTIVE and you will lose access to member benefits.
+
+Please make your payment immediately to avoid interruption:
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Pay Now - Urgent
+
'
+WHERE template_key = 'dues_grace_warning';
+
+-- Inactive notice
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Due to non-payment of membership dues, your Monaco USA membership has been marked as INACTIVE .
+
+
Status Change:
+
Previous Status: Active
+
New Status: Inactive
+
Outstanding Amount: {{amount}}
+
+As an inactive member, you will no longer have access to:
+
+ Member-only events
+ Member directory
+ Member communications
+ Voting rights
+
+To reactivate your membership, please pay your outstanding dues:
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Reactivate My Membership
+
+If you believe this is an error or have questions, please contact us at contact@monacousa.org
'
+WHERE template_key = 'dues_inactive_notice';
+
+-- ============================================
+-- UPDATE EVENT REMINDER TEMPLATE
+-- ============================================
+UPDATE public.email_templates
+SET body_html = 'Hi {{first_name}},
+This is a friendly reminder that {{event_title}} is happening tomorrow!
+
+
Event Details:
+
Date: {{event_date}}
+
Time: {{event_time}}
+
Location: {{event_location}}
+ {{#if guest_count}}
+
You''re bringing {{guest_count}} guest(s)
+ {{/if}}
+
+We look forward to seeing you there!
+
+ View Event Details
+
+Can''t make it? Please update your RSVP so we can offer your spot to someone on the waitlist.
'
+WHERE template_key = 'event_reminder_24hr';
+
+-- ============================================
+-- UPDATE RSVP CONFIRMATION TEMPLATE (content-only version)
+-- ============================================
+-- Note: The original is a full HTML template, so we'll create a content-only version
+-- that works with wrapInMonacoTemplate
+
+-- Update waitlist promotion to be content-only with proper styling
+UPDATE public.email_templates
+SET body_html = 'Dear {{first_name}},
+Great news! A spot has opened up for {{event_title}} and you have been moved from the waitlist to confirmed!
+
+
Event Details
+
Date: {{event_date}}
+
Location: {{event_location}}
+
+We look forward to seeing you there!
+Best regards,The Monaco USA Team
'
+WHERE template_key = 'waitlist_promotion';
diff --git a/supabase/migrations/016_onboarding_payment_tracking.sql b/supabase/migrations/016_onboarding_payment_tracking.sql
new file mode 100644
index 0000000..8af7ad0
--- /dev/null
+++ b/supabase/migrations/016_onboarding_payment_tracking.sql
@@ -0,0 +1,237 @@
+-- Monaco USA Portal 2026 - Onboarding Payment Tracking
+-- Track new member payment deadlines for the 30-day payment window
+
+-- ============================================
+-- ADD PAYMENT TRACKING COLUMNS TO MEMBERS
+-- ============================================
+
+-- Payment deadline for new signups (30 days from onboarding completion)
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ;
+
+-- Track when onboarding was completed
+ALTER TABLE public.members ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ;
+
+-- Index for efficient reminder queries (only index non-null deadlines)
+CREATE INDEX IF NOT EXISTS idx_members_payment_deadline ON public.members(payment_deadline)
+ WHERE payment_deadline IS NOT NULL;
+
+-- ============================================
+-- ADD ONBOARDING REMINDER TYPES TO LOGS TABLE
+-- ============================================
+
+-- Update the check constraint on dues_reminder_logs to include onboarding types
+ALTER TABLE public.dues_reminder_logs DROP CONSTRAINT IF EXISTS dues_reminder_logs_reminder_type_check;
+ALTER TABLE public.dues_reminder_logs ADD CONSTRAINT dues_reminder_logs_reminder_type_check
+ CHECK (reminder_type IN (
+ 'due_soon_30', 'due_soon_7', 'due_soon_1', 'overdue', 'grace_period', 'inactive_notice',
+ 'onboarding_welcome', 'onboarding_reminder_7', 'onboarding_reminder_1', 'onboarding_expired'
+ ));
+
+-- ============================================
+-- ADD EMAIL TEMPLATES FOR ONBOARDING REMINDERS
+-- ============================================
+
+-- Welcome email with payment instructions (sent immediately after onboarding)
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'onboarding_welcome',
+ 'Welcome - Complete Your Membership',
+ 'onboarding',
+ 'Welcome to Monaco USA - Complete Your Membership',
+ 'Dear {{first_name}},
+Welcome to Monaco USA! We''re thrilled to have you join our community of Americans living in and connected to Monaco.
+Your account has been created and you now have 30 days to complete your membership by paying your annual dues.
+
+
Your Membership Details:
+
Member ID: {{member_id}}
+
Annual Dues: {{amount}}
+
Payment Deadline: {{payment_deadline}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+In the meantime, explore your new member dashboard and connect with our community:
+
+ Go to Dashboard
+
+Questions? Contact us at contact@monacousa.org
',
+ 'Dear {{first_name}},
+
+Welcome to Monaco USA! We''re thrilled to have you join our community of Americans living in and connected to Monaco.
+
+Your account has been created and you now have 30 days to complete your membership by paying your annual dues.
+
+Your Membership Details:
+- Member ID: {{member_id}}
+- Annual Dues: {{amount}}
+- Payment Deadline: {{payment_deadline}}
+
+Bank Transfer Details:
+- Account Holder: {{account_holder}}
+- Bank: {{bank_name}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+Visit your dashboard: {{portal_url}}
+
+Questions? Contact us at contact@monacousa.org',
+ true,
+ true,
+ '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}'
+) ON CONFLICT (template_key) DO NOTHING;
+
+-- 7 days left reminder
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'onboarding_reminder_7',
+ 'Onboarding Reminder - 7 Days Left',
+ 'onboarding',
+ '7 Days Left to Complete Your Monaco USA Membership',
+ 'Dear {{first_name}},
+You have 7 days left to complete your Monaco USA membership by paying your annual dues.
+
+
Payment Due:
+
Amount: {{amount}}
+
Deadline: {{payment_deadline}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+After the deadline, your account will be marked as inactive and you''ll lose access to member features.
+
+ View My Account
+
',
+ 'Dear {{first_name}},
+
+You have 7 days left to complete your Monaco USA membership by paying your annual dues.
+
+Payment Due:
+- Amount: {{amount}}
+- Deadline: {{payment_deadline}}
+
+Bank Transfer Details:
+- Account Holder: {{account_holder}}
+- Bank: {{bank_name}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+After the deadline, your account will be marked as inactive.
+
+Visit: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}'
+) ON CONFLICT (template_key) DO NOTHING;
+
+-- Last day reminder (urgent)
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'onboarding_reminder_1',
+ 'Onboarding Reminder - Last Day',
+ 'onboarding',
+ 'URGENT: Last Day to Complete Your Monaco USA Membership',
+ 'Dear {{first_name}},
+Today is your last day to complete your Monaco USA membership payment.
+
+
Payment Required Today:
+
Amount: {{amount}}
+
Deadline: {{payment_deadline}}
+
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+If we don''t receive your payment today, your account will be marked as inactive tomorrow.
+
+ Complete Payment Now
+
',
+ 'Dear {{first_name}},
+
+TODAY IS YOUR LAST DAY to complete your Monaco USA membership payment.
+
+Payment Required Today:
+- Amount: {{amount}}
+- Deadline: {{payment_deadline}}
+
+Bank Transfer Details:
+- Account Holder: {{account_holder}}
+- Bank: {{bank_name}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+If we don''t receive your payment today, your account will be marked as inactive tomorrow.
+
+Visit: {{portal_url}}',
+ true,
+ true,
+ '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "payment_deadline": "Payment deadline date", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}'
+) ON CONFLICT (template_key) DO NOTHING;
+
+-- Account marked inactive (deadline passed)
+INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active, is_system, variables_schema) VALUES
+(
+ 'onboarding_expired',
+ 'Onboarding Expired - Account Inactive',
+ 'onboarding',
+ 'Your Monaco USA Account Has Been Marked Inactive',
+ 'Dear {{first_name}},
+Your 30-day payment window has expired and your Monaco USA account has been marked as INACTIVE .
+
+
Account Status:
+
Status: Inactive
+
Outstanding Amount: {{amount}}
+
+As an inactive member, you no longer have access to:
+
+ Member-only events
+ Member directory
+ Member communications
+
+To reactivate your membership, please complete your dues payment:
+
+
Bank Transfer Details:
+
Account Holder: {{account_holder}}
+
Bank: {{bank_name}}
+
IBAN: {{iban}}
+
Reference: {{member_id}}
+
+
+ Reactivate My Account
+
+Questions? Contact us at contact@monacousa.org
',
+ 'Dear {{first_name}},
+
+Your 30-day payment window has expired and your Monaco USA account has been marked as INACTIVE.
+
+Account Status:
+- Status: Inactive
+- Outstanding Amount: {{amount}}
+
+As an inactive member, you no longer have access to member-only events, directory, and communications.
+
+To reactivate your membership, please pay your dues:
+
+Bank Transfer Details:
+- Account Holder: {{account_holder}}
+- Bank: {{bank_name}}
+- IBAN: {{iban}}
+- Reference: {{member_id}}
+
+Visit: {{portal_url}}
+
+Questions? Contact us at contact@monacousa.org',
+ true,
+ true,
+ '{"first_name": "Member first name", "member_id": "Member ID", "amount": "Annual dues amount", "account_holder": "Bank account holder", "bank_name": "Bank name", "iban": "IBAN number", "portal_url": "Portal URL"}'
+) ON CONFLICT (template_key) DO NOTHING;
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..e0a641e
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,18 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://svelte.dev/docs/kit/integrations
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+
+ kit: {
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..2c2ed3c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "rewriteRelativeImportExtensions": true,
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // To make changes to top-level options such as include and exclude, we recommend extending
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..9b140e6
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,11 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ css: {
+ // Prevent Vite from picking up postcss config from parent directories
+ postcss: {}
+ }
+});