Initial production deployment setup
- Production docker-compose with nginx support - Nginx configuration for portal.monacousa.org - Deployment script with backup/restore - Gitea CI/CD workflow - Fix CountryFlag reactivity for dropdown flags Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
e7338d1a70
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-*
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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 <backup_file.sql.gz>"
|
||||||
|
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 <file> 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: {}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Database>;
|
||||||
|
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 {};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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<Database>(
|
||||||
|
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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Mail, X, RefreshCw, Check } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { email }: Props = $props();
|
||||||
|
|
||||||
|
let dismissed = $state(false);
|
||||||
|
let resending = $state(false);
|
||||||
|
let resendSuccess = $state(false);
|
||||||
|
|
||||||
|
async function resendVerification() {
|
||||||
|
resending = true;
|
||||||
|
resendSuccess = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/resend-verification', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
resendSuccess = true;
|
||||||
|
// Reset success message after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
resendSuccess = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resend verification:', error);
|
||||||
|
} finally {
|
||||||
|
resending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !dismissed}
|
||||||
|
<div class="bg-amber-50 border-b border-amber-200">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<Mail class="h-4 w-4 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-amber-800">
|
||||||
|
Please verify your email address
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-600 truncate">
|
||||||
|
Check <span class="font-medium">{email}</span> for verification link
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 sm:shrink-0">
|
||||||
|
{#if resendSuccess}
|
||||||
|
<span class="flex items-center gap-1.5 text-sm text-green-600">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
Email sent!
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={resendVerification}
|
||||||
|
disabled={resending}
|
||||||
|
class="text-amber-700 hover:bg-amber-100 hover:text-amber-800"
|
||||||
|
>
|
||||||
|
{#if resending}
|
||||||
|
<RefreshCw class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
{:else}
|
||||||
|
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Resend
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (dismissed = true)}
|
||||||
|
class="text-amber-600 hover:bg-amber-100 hover:text-amber-800 p-1.5"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
value?: string;
|
||||||
|
error?: string;
|
||||||
|
autocomplete?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
type = 'text',
|
||||||
|
placeholder = '',
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
value = $bindable(''),
|
||||||
|
error = '',
|
||||||
|
autocomplete = ''
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for={name} class="text-sm font-medium text-slate-700">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-monaco-600">*</span>
|
||||||
|
{/if}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
{name}
|
||||||
|
{type}
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
{autocomplete}
|
||||||
|
bind:value
|
||||||
|
class="h-11 transition-all focus:ring-2 focus:ring-monaco-500/20 {error ? 'border-red-500 focus:border-red-500' : ''}"
|
||||||
|
/>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-xs text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { AlertCircle, CheckCircle2, Info } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type?: 'error' | 'success' | 'info';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { type = 'info', message }: Props = $props();
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
error: AlertCircle,
|
||||||
|
success: CheckCircle2,
|
||||||
|
info: Info
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<div class="flex items-start gap-3 rounded-lg border p-3 {styles[type]}">
|
||||||
|
<svelte:component this={icons[type]} class="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p class="text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 'md', class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-5 w-5',
|
||||||
|
lg: 'h-6 w-6'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
class="animate-spin {sizes[size]} {className}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
import { CreditCard, Calendar, AlertCircle, CheckCircle2, Clock } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member }: Props = $props();
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dues status info
|
||||||
|
function getDuesInfo(status: string | null) {
|
||||||
|
switch (status) {
|
||||||
|
case 'current':
|
||||||
|
return {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
border: 'border-green-200',
|
||||||
|
label: 'Dues Current',
|
||||||
|
description: 'Your membership dues are paid and up to date.'
|
||||||
|
};
|
||||||
|
case 'due_soon':
|
||||||
|
return {
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-yellow-600',
|
||||||
|
bg: 'bg-yellow-50',
|
||||||
|
border: 'border-yellow-200',
|
||||||
|
label: 'Due Soon',
|
||||||
|
description: `Your dues are due on ${formatDate(member.current_due_date)}.`
|
||||||
|
};
|
||||||
|
case 'overdue':
|
||||||
|
return {
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
border: 'border-red-200',
|
||||||
|
label: 'Overdue',
|
||||||
|
description: `Your dues are ${member.days_overdue} days overdue.`
|
||||||
|
};
|
||||||
|
case 'never_paid':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: CreditCard,
|
||||||
|
color: 'text-slate-600',
|
||||||
|
bg: 'bg-slate-50',
|
||||||
|
border: 'border-slate-200',
|
||||||
|
label: 'Payment Required',
|
||||||
|
description: 'Please pay your membership dues to activate your membership.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duesInfo = getDuesInfo(member.dues_status);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
<div class="border-b border-slate-200/50 px-6 py-4">
|
||||||
|
<h3 class="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||||
|
<CreditCard class="h-5 w-5 text-monaco-600" />
|
||||||
|
Dues Status
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-start gap-4 rounded-lg border p-4 {duesInfo.bg} {duesInfo.border}">
|
||||||
|
<svelte:component this={duesInfo.icon} class="h-6 w-6 flex-shrink-0 {duesInfo.color}" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium {duesInfo.color}">{duesInfo.label}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">{duesInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Annual Dues</p>
|
||||||
|
<p class="font-semibold text-slate-900">
|
||||||
|
{member.annual_dues ? `€${member.annual_dues.toFixed(2)}` : '€50.00'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Membership Type</p>
|
||||||
|
<p class="font-semibold text-slate-900">{member.membership_type_name || 'Regular'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Last Payment</p>
|
||||||
|
<p class="font-semibold text-slate-900">{formatDate(member.last_payment_date)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Next Due Date</p>
|
||||||
|
<p class="font-semibold text-slate-900">{formatDate(member.current_due_date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<Button href="/payments" variant="monaco" size="sm" class="w-full">
|
||||||
|
<Calendar class="mr-2 h-4 w-4" />
|
||||||
|
View Payment Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { User, Calendar, CreditCard, FileText, Users, Settings } from 'lucide-svelte';
|
||||||
|
import type { MemberRole } from '$lib/types/database';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: MemberRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { role }: Props = $props();
|
||||||
|
|
||||||
|
const isBoard = role === 'board' || role === 'admin';
|
||||||
|
const isAdmin = role === 'admin';
|
||||||
|
|
||||||
|
interface QuickAction {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: typeof User;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
href: '/profile',
|
||||||
|
label: 'Edit Profile',
|
||||||
|
description: 'Update your information',
|
||||||
|
icon: User,
|
||||||
|
color: 'bg-blue-100 text-blue-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/events',
|
||||||
|
label: 'Browse Events',
|
||||||
|
description: 'Find upcoming events',
|
||||||
|
icon: Calendar,
|
||||||
|
color: 'bg-green-100 text-green-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/payments',
|
||||||
|
label: 'View Payments',
|
||||||
|
description: 'Check payment history',
|
||||||
|
icon: CreditCard,
|
||||||
|
color: 'bg-purple-100 text-purple-600'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/documents',
|
||||||
|
label: 'Documents',
|
||||||
|
description: 'Access resources',
|
||||||
|
icon: FileText,
|
||||||
|
color: 'bg-orange-100 text-orange-600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const boardActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
href: '/board/members',
|
||||||
|
label: 'Manage Members',
|
||||||
|
description: 'View member directory',
|
||||||
|
icon: Users,
|
||||||
|
color: 'bg-indigo-100 text-indigo-600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
href: '/admin/settings',
|
||||||
|
label: 'Settings',
|
||||||
|
description: 'Configure system',
|
||||||
|
icon: Settings,
|
||||||
|
color: 'bg-slate-100 text-slate-600'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let actions = $derived([
|
||||||
|
...memberActions,
|
||||||
|
...(isBoard ? boardActions : []),
|
||||||
|
...(isAdmin ? adminActions : [])
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<h3 class="mb-4 text-base font-semibold text-slate-900">Quick Actions</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{#each actions.slice(0, 4) as action}
|
||||||
|
<a
|
||||||
|
href={action.href}
|
||||||
|
class="flex flex-col items-center rounded-xl border border-slate-100 p-4 text-center transition-all hover:border-slate-200 hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full {action.color}">
|
||||||
|
<svelte:component this={action.icon} class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-slate-900">{action.label}</span>
|
||||||
|
<span class="mt-0.5 text-xs text-slate-500">{action.description}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
description?: string;
|
||||||
|
icon: Component;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
color?: 'default' | 'green' | 'yellow' | 'red' | 'blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, value, description, icon, trend, color = 'default' }: Props = $props();
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
default: 'bg-slate-100 text-slate-600',
|
||||||
|
green: 'bg-green-100 text-green-600',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-600',
|
||||||
|
red: 'bg-red-100 text-red-600',
|
||||||
|
blue: 'bg-blue-100 text-blue-600'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="glass-card p-5">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-slate-500">{title}</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-slate-900">{value}</p>
|
||||||
|
{#if description}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if trend}
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
<span class={trend.value >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{trend.value >= 0 ? '+' : ''}{trend.value}%
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-500">{trend.label}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg {colorClasses[color]}">
|
||||||
|
<svelte:component this={icon} class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { EventWithCounts } from '$lib/types/database';
|
||||||
|
import { Calendar, MapPin, Users, ArrowRight } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: EventWithCounts[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { events }: Props = $props();
|
||||||
|
|
||||||
|
// Format date and time
|
||||||
|
function formatDateTime(dateStr: string): { date: string; time: string } {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return {
|
||||||
|
date: date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}),
|
||||||
|
time: date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event type color
|
||||||
|
function getTypeColor(color: string | null): string {
|
||||||
|
return color || '#6b7280';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200/50 px-6 py-4">
|
||||||
|
<h3 class="flex items-center gap-2 text-base font-semibold text-slate-900">
|
||||||
|
<Calendar class="h-5 w-5 text-monaco-600" />
|
||||||
|
Upcoming Events
|
||||||
|
</h3>
|
||||||
|
<a href="/events" class="text-sm font-medium text-monaco-600 hover:text-monaco-700">
|
||||||
|
View all
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
{#if events.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Calendar class="mb-3 h-12 w-12 text-slate-300" />
|
||||||
|
<p class="text-sm font-medium text-slate-900">No upcoming events</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Check back later for new events.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each events as event}
|
||||||
|
{@const { date, time } = formatDateTime(event.start_datetime)}
|
||||||
|
<a href="/events/{event.id}" class="block p-4 transition-colors hover:bg-slate-50">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Date badge -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center rounded-lg bg-slate-100 px-3 py-2 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium uppercase text-slate-500">
|
||||||
|
{date.split(' ')[1]}
|
||||||
|
</span>
|
||||||
|
<span class="text-xl font-bold text-slate-900">
|
||||||
|
{date.split(' ')[2]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2 w-2 rounded-full"
|
||||||
|
style="background-color: {getTypeColor(event.event_type_color)}"
|
||||||
|
></span>
|
||||||
|
<span class="text-xs font-medium text-slate-500">
|
||||||
|
{event.event_type_name || 'Event'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="mt-1 truncate font-medium text-slate-900">{event.title}</h4>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Calendar class="h-3 w-3" />
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
{#if event.location}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<MapPin class="h-3 w-3" />
|
||||||
|
{event.location}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Users class="h-3 w-3" />
|
||||||
|
{event.total_attendees}
|
||||||
|
{event.max_attendees ? ` / ${event.max_attendees}` : ''} attending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight class="h-5 w-5 flex-shrink-0 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
import CountryFlag from '$lib/components/ui/CountryFlag.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member }: Props = $props();
|
||||||
|
|
||||||
|
// Get greeting based on time of day
|
||||||
|
function getGreeting(): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 12) return 'Good morning';
|
||||||
|
if (hour < 18) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge styling
|
||||||
|
function getStatusBadge(status: string | null) {
|
||||||
|
const styles: Record<string, { bg: string; text: string }> = {
|
||||||
|
active: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
|
inactive: { bg: 'bg-gray-100', text: 'text-gray-700' },
|
||||||
|
expired: { bg: 'bg-red-100', text: 'text-red-700' }
|
||||||
|
};
|
||||||
|
return styles[status || 'pending'] || styles.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusStyle = getStatusBadge(member.status_name);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Avatar -->
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
class="h-16 w-16 rounded-full object-cover ring-4 ring-white shadow-md"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-monaco-100 text-xl font-semibold text-monaco-700 ring-4 ring-white shadow-md"
|
||||||
|
>
|
||||||
|
{member.first_name[0]}{member.last_name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">{getGreeting()},</p>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">
|
||||||
|
{member.first_name}
|
||||||
|
{member.last_name}
|
||||||
|
</h2>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="text-sm text-slate-500">{member.member_id}</span>
|
||||||
|
<span class="text-slate-300">|</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusStyle.bg} {statusStyle.text}"
|
||||||
|
>
|
||||||
|
{member.status_display_name || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nationality flags -->
|
||||||
|
{#if member.nationality && member.nationality.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#each member.nationality as code}
|
||||||
|
<CountryFlag {code} size="xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X, FolderPlus } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
parentFolderId = null,
|
||||||
|
onClose,
|
||||||
|
onSuccess
|
||||||
|
}: {
|
||||||
|
parentFolderId?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let folderName = $state('');
|
||||||
|
let visibility = $state('members');
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Modal Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="create-folder-title"
|
||||||
|
>
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="w-full max-w-md rounded-2xl bg-white shadow-xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
|
||||||
|
<FolderPlus class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<h2 id="create-folder-title" class="text-lg font-semibold text-slate-900">
|
||||||
|
Create New Folder
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/createFolder"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSubmitting = true;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
isSubmitting = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
onClose();
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="p-6 space-y-4"
|
||||||
|
>
|
||||||
|
{#if parentFolderId}
|
||||||
|
<input type="hidden" name="parent_id" value={parentFolderId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="folder-name">Folder Name</Label>
|
||||||
|
<Input
|
||||||
|
id="folder-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={folderName}
|
||||||
|
placeholder="Enter folder name..."
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="folder-visibility">Visibility</Label>
|
||||||
|
<select
|
||||||
|
id="folder-visibility"
|
||||||
|
name="visibility"
|
||||||
|
bind:value={visibility}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="w-full h-11 rounded-lg border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||||
|
>
|
||||||
|
<option value="members">Members Only</option>
|
||||||
|
<option value="board">Board Only</option>
|
||||||
|
<option value="admin">Admin Only</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
Who can see this folder and its contents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onclick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="monaco"
|
||||||
|
disabled={isSubmitting || !folderName.trim()}
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<div class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||||
|
Creating...
|
||||||
|
{:else}
|
||||||
|
<FolderPlus class="mr-2 h-4 w-4" />
|
||||||
|
Create Folder
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { X, Download, ZoomIn, ZoomOut, RotateCw, Maximize2, FileText, Image, File } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { document, previewUrl, onClose }: {
|
||||||
|
document: Document;
|
||||||
|
previewUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let zoom = $state(100);
|
||||||
|
let rotation = $state(0);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let loadError = $state(false);
|
||||||
|
let textContent = $state<string | null>(null);
|
||||||
|
|
||||||
|
const isPdf = $derived(document.mime_type === 'application/pdf');
|
||||||
|
const isImage = $derived(document.mime_type.startsWith('image/'));
|
||||||
|
const isText = $derived(
|
||||||
|
document.mime_type.startsWith('text/') ||
|
||||||
|
['application/json', 'application/javascript', 'text/csv'].includes(document.mime_type)
|
||||||
|
);
|
||||||
|
const isOffice = $derived(
|
||||||
|
document.mime_type.includes('word') ||
|
||||||
|
document.mime_type.includes('excel') ||
|
||||||
|
document.mime_type.includes('spreadsheet') ||
|
||||||
|
document.mime_type.includes('powerpoint') ||
|
||||||
|
document.mime_type.includes('presentation')
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomIn() {
|
||||||
|
zoom = Math.min(zoom + 25, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomOut() {
|
||||||
|
zoom = Math.max(zoom - 25, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRotate() {
|
||||||
|
rotation = (rotation + 90) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = previewUrl;
|
||||||
|
link.download = document.file_name;
|
||||||
|
link.target = '_blank';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load text content for text files
|
||||||
|
$effect(() => {
|
||||||
|
if (isText && previewUrl) {
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then(res => res.text())
|
||||||
|
.then(text => {
|
||||||
|
textContent = text;
|
||||||
|
isLoading = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loadError = true;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Modal Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="preview-title"
|
||||||
|
>
|
||||||
|
<!-- Modal Container -->
|
||||||
|
<div class="relative flex h-full w-full max-w-6xl flex-col rounded-2xl bg-slate-900/95 shadow-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-700/50 px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if isPdf}
|
||||||
|
<FileText class="h-6 w-6 text-red-400" />
|
||||||
|
{:else if isImage}
|
||||||
|
<Image class="h-6 w-6 text-blue-400" />
|
||||||
|
{:else}
|
||||||
|
<File class="h-6 w-6 text-slate-400" />
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<h2 id="preview-title" class="text-lg font-semibold text-white">{document.title}</h2>
|
||||||
|
<p class="text-sm text-slate-400">{document.file_name} · {formatFileSize(document.file_size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Zoom Controls (for images) -->
|
||||||
|
{#if isImage}
|
||||||
|
<div class="flex items-center gap-1 rounded-lg bg-slate-800 px-2 py-1">
|
||||||
|
<button
|
||||||
|
onclick={handleZoomOut}
|
||||||
|
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<ZoomOut class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span class="min-w-[3rem] text-center text-sm text-slate-300">{zoom}%</span>
|
||||||
|
<button
|
||||||
|
onclick={handleZoomIn}
|
||||||
|
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleRotate}
|
||||||
|
class="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||||
|
aria-label="Rotate"
|
||||||
|
>
|
||||||
|
<RotateCw class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={handleDownload}
|
||||||
|
class="border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg p-2 text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
<X class="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="relative flex-1 overflow-auto p-4">
|
||||||
|
{#if isLoading && !isImage && !isPdf}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<div class="h-8 w-8 animate-spin rounded-full border-2 border-monaco-600 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
|
||||||
|
<File class="h-16 w-16" />
|
||||||
|
<p>Unable to load preview</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={handleDownload}
|
||||||
|
class="border-slate-600 bg-slate-800 text-white hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else if isPdf}
|
||||||
|
<!-- PDF Preview -->
|
||||||
|
<iframe
|
||||||
|
src={previewUrl}
|
||||||
|
class="h-full w-full rounded-lg bg-white"
|
||||||
|
title={document.title}
|
||||||
|
onload={() => isLoading = false}
|
||||||
|
onerror={() => { loadError = true; isLoading = false; }}
|
||||||
|
></iframe>
|
||||||
|
{:else if isImage}
|
||||||
|
<!-- Image Preview -->
|
||||||
|
<div class="flex h-full items-center justify-center overflow-auto">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={document.title}
|
||||||
|
class="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||||
|
style="transform: scale({zoom / 100}) rotate({rotation}deg);"
|
||||||
|
onload={() => isLoading = false}
|
||||||
|
onerror={() => { loadError = true; isLoading = false; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if isText && textContent !== null}
|
||||||
|
<!-- Text Preview -->
|
||||||
|
<div class="h-full overflow-auto rounded-lg bg-slate-950 p-4">
|
||||||
|
<pre class="whitespace-pre-wrap font-mono text-sm text-slate-300">{textContent}</pre>
|
||||||
|
</div>
|
||||||
|
{:else if isOffice}
|
||||||
|
<!-- Office Documents - Offer Download -->
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
|
||||||
|
<File class="h-16 w-16" />
|
||||||
|
<p class="text-lg">Office documents cannot be previewed directly</p>
|
||||||
|
<p class="text-sm">Download the file to view it in Microsoft Office or compatible application</p>
|
||||||
|
<Button
|
||||||
|
variant="monaco"
|
||||||
|
onclick={handleDownload}
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download {document.file_name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Unsupported file type -->
|
||||||
|
<div class="flex h-full flex-col items-center justify-center gap-4 text-slate-400">
|
||||||
|
<File class="h-16 w-16" />
|
||||||
|
<p class="text-lg">Preview not available for this file type</p>
|
||||||
|
<Button
|
||||||
|
variant="monaco"
|
||||||
|
onclick={handleDownload}
|
||||||
|
>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download File
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { ChevronRight, Folder, Home } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Breadcrumb {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
breadcrumbs,
|
||||||
|
onNavigate
|
||||||
|
}: {
|
||||||
|
breadcrumbs: Breadcrumb[];
|
||||||
|
onNavigate: (folderId: string | null) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav aria-label="Breadcrumb" class="flex items-center gap-1 text-sm">
|
||||||
|
{#each breadcrumbs as crumb, index}
|
||||||
|
{#if index > 0}
|
||||||
|
<ChevronRight class="h-4 w-4 text-slate-400" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if index === breadcrumbs.length - 1}
|
||||||
|
<!-- Current page (not clickable) -->
|
||||||
|
<span class="flex items-center gap-1.5 px-2 py-1 font-medium text-slate-900">
|
||||||
|
{#if index === 0}
|
||||||
|
<Home class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Folder class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
{crumb.name}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<!-- Clickable breadcrumb -->
|
||||||
|
<button
|
||||||
|
onclick={() => 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}
|
||||||
|
<Home class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Folder class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
{crumb.name}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Folder, MoreVertical, Edit2, Trash2 } from 'lucide-svelte';
|
||||||
|
import { DropdownMenu } from 'bits-ui';
|
||||||
|
|
||||||
|
interface FolderData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visibility: string;
|
||||||
|
created_at: string;
|
||||||
|
creator?: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
folder,
|
||||||
|
canEdit = false,
|
||||||
|
canDelete = false,
|
||||||
|
onNavigate,
|
||||||
|
onRename,
|
||||||
|
onDelete
|
||||||
|
}: {
|
||||||
|
folder: FolderData;
|
||||||
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
onNavigate: (folderId: string) => void;
|
||||||
|
onRename?: (folder: FolderData) => void;
|
||||||
|
onDelete?: (folder: FolderData) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const visibilityColors: Record<string, string> = {
|
||||||
|
public: 'text-green-600',
|
||||||
|
members: 'text-blue-600',
|
||||||
|
board: 'text-purple-600',
|
||||||
|
admin: 'text-red-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
onNavigate(folder.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate(folder.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group relative flex items-center gap-3 rounded-xl border border-slate-200/60 bg-white/50 p-4 backdrop-blur-sm transition-all hover:border-monaco-200 hover:bg-white/80 hover:shadow-md cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
>
|
||||||
|
<!-- Folder Icon -->
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-amber-100 text-amber-600">
|
||||||
|
<Folder class="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-medium text-slate-900 truncate">{folder.name}</h3>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span class={visibilityColors[folder.visibility] || 'text-slate-500'}>
|
||||||
|
{folder.visibility}
|
||||||
|
</span>
|
||||||
|
{#if folder.creator}
|
||||||
|
<span>·</span>
|
||||||
|
<span>{folder.creator.first_name} {folder.creator.last_name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Menu -->
|
||||||
|
{#if canEdit || canDelete}
|
||||||
|
<div
|
||||||
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
aria-label="Folder options"
|
||||||
|
>
|
||||||
|
<MoreVertical class="h-4 w-4" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="end"
|
||||||
|
class="z-50 min-w-48 rounded-lg border border-slate-200 bg-white p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
{#if canEdit && onRename}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 focus:bg-slate-100 focus:outline-none"
|
||||||
|
onSelect={() => onRename(folder)}
|
||||||
|
>
|
||||||
|
<Edit2 class="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
{#if canDelete && onDelete}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm text-red-600 hover:bg-red-50 focus:bg-red-50 focus:outline-none"
|
||||||
|
onSelect={() => onDelete(folder)}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Bell, Search, Menu, User, Settings, LogOut, ChevronDown } from 'lucide-svelte';
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues | null;
|
||||||
|
title?: string;
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member, title = 'Dashboard', onMenuToggle }: Props = $props();
|
||||||
|
|
||||||
|
let showUserMenu = $state(false);
|
||||||
|
|
||||||
|
function toggleUserMenu() {
|
||||||
|
showUserMenu = !showUserMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserMenu() {
|
||||||
|
showUserMenu = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.user-menu-container')) {
|
||||||
|
closeUserMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} />
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-slate-200/50 bg-white/80 px-4 backdrop-blur-sm lg:px-6"
|
||||||
|
>
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
onclick={onMenuToggle}
|
||||||
|
class="rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 lg:hidden transition-colors min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<Menu class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page title -->
|
||||||
|
<h1 class="text-lg font-semibold text-slate-900 lg:text-xl truncate max-w-[200px] sm:max-w-none">{title}</h1>
|
||||||
|
|
||||||
|
<!-- Right section -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Search button -->
|
||||||
|
<button
|
||||||
|
class="hidden rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 sm:block transition-colors min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<Search class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<button
|
||||||
|
class="relative rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 transition-colors min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell class="h-5 w-5" />
|
||||||
|
<!-- Notification badge -->
|
||||||
|
<span
|
||||||
|
class="absolute right-1 top-1 flex h-2 w-2 items-center justify-center rounded-full bg-monaco-600"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- User menu (desktop) -->
|
||||||
|
{#if member}
|
||||||
|
<div class="user-menu-container relative hidden lg:block">
|
||||||
|
<button
|
||||||
|
onclick={toggleUserMenu}
|
||||||
|
class="flex items-center gap-2 rounded-lg p-2 hover:bg-slate-100 transition-colors"
|
||||||
|
aria-expanded={showUserMenu}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-label="User menu"
|
||||||
|
>
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
class="h-8 w-8 rounded-full object-cover ring-2 ring-transparent hover:ring-monaco-200"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-monaco-100 text-sm font-medium text-monaco-700"
|
||||||
|
>
|
||||||
|
{member.first_name[0]}{member.last_name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ChevronDown class="h-4 w-4 text-slate-400 transition-transform {showUserMenu ? 'rotate-180' : ''}" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dropdown Menu -->
|
||||||
|
{#if showUserMenu}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-2 w-56 origin-top-right rounded-xl border border-slate-200 bg-white py-2 shadow-lg ring-1 ring-black/5"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<!-- User info header -->
|
||||||
|
<div class="border-b border-slate-100 px-4 pb-3 pt-2">
|
||||||
|
<p class="text-sm font-medium text-slate-900">
|
||||||
|
{member.first_name} {member.last_name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">{member.email}</p>
|
||||||
|
<p class="mt-1 text-xs text-monaco-600">{member.member_id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu items -->
|
||||||
|
<div class="py-1">
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
onclick={closeUserMenu}
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<User class="h-4 w-4 text-slate-400" />
|
||||||
|
View Profile
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
onclick={closeUserMenu}
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Settings class="h-4 w-4 text-slate-400" />
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sign out -->
|
||||||
|
<div class="border-t border-slate-100 pt-1">
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
CalendarPlus,
|
||||||
|
Shield,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
FolderOpen
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues | null;
|
||||||
|
currentPath: string;
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member, currentPath, open = false, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const isBoard = $derived(member?.role === 'board' || member?.role === 'admin');
|
||||||
|
const isAdmin = $derived(member?.role === 'admin');
|
||||||
|
|
||||||
|
// Body scroll lock effect
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' || event.key === 'Enter' || event.key === ' ') {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardNav = [
|
||||||
|
{ href: '/board/members', label: 'Members', icon: Users },
|
||||||
|
{ href: '/board/dues', label: 'Dues Management', icon: DollarSign },
|
||||||
|
{ href: '/board/events', label: 'Manage Events', icon: CalendarPlus }
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNav = [
|
||||||
|
{ href: '/admin/members', label: 'User Management', icon: Shield },
|
||||||
|
{ href: '/admin/settings', label: 'Settings', icon: Settings }
|
||||||
|
];
|
||||||
|
|
||||||
|
const extraNav = [{ href: '/documents', label: 'Documents', icon: FolderOpen }];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 lg:hidden"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Close menu"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Menu panel -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-y-0 right-0 z-50 w-72 bg-white shadow-xl lg:hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Navigation menu"
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex h-16 items-center justify-between border-b border-slate-200 px-4">
|
||||||
|
<span class="font-semibold text-slate-900">Menu</span>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-lg p-2 text-slate-500 hover:bg-slate-100"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 overflow-y-auto p-4">
|
||||||
|
<!-- Extra nav items -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each extraNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onclick={onClose}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-3 text-sm min-h-[44px] font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Board Navigation -->
|
||||||
|
{#if isBoard}
|
||||||
|
<div class="my-4 border-t border-slate-200"></div>
|
||||||
|
<p class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
Board
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each boardNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onclick={onClose}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-3 text-sm min-h-[44px] font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Admin Navigation -->
|
||||||
|
{#if isAdmin}
|
||||||
|
<div class="my-4 border-t border-slate-200"></div>
|
||||||
|
<p class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each adminNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
onclick={onClose}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-3 text-sm min-h-[44px] font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- User Section -->
|
||||||
|
<div class="border-t border-slate-200 p-4">
|
||||||
|
{#if member}
|
||||||
|
<div class="mb-3 flex items-center gap-3 rounded-lg bg-slate-50 p-3">
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
class="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-sm font-medium text-monaco-700"
|
||||||
|
>
|
||||||
|
{member.first_name[0]}{member.last_name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<p class="truncate text-sm font-medium text-slate-900">
|
||||||
|
{member.first_name}
|
||||||
|
{member.last_name}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-slate-500">{member.member_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg px-3 py-3 text-sm min-h-[44px] font-medium text-slate-600 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut class="h-5 w-5" />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { LayoutDashboard, User, Calendar, CreditCard, Menu } from 'lucide-svelte';
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues | null;
|
||||||
|
currentPath: string;
|
||||||
|
onMenuOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member, currentPath, onMenuOpen }: Props = $props();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Home', icon: LayoutDashboard },
|
||||||
|
{ href: '/events', label: 'Events', icon: Calendar },
|
||||||
|
{ href: '/profile', label: 'Profile', icon: User },
|
||||||
|
{ href: '/payments', label: 'Payments', icon: CreditCard }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === '/dashboard') return currentPath === href;
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white/95 backdrop-blur-sm lg:hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-around">
|
||||||
|
{#each navItems as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex flex-1 flex-col items-center gap-1 py-3 text-sm font-medium transition-colors min-h-[56px] {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'text-monaco-600 bg-monaco-50/50'
|
||||||
|
: 'text-slate-500 hover:text-slate-900 active:bg-slate-100'}"
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5 {isActive(item.href) ? 'text-monaco-600' : ''}" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
onclick={onMenuOpen}
|
||||||
|
class="flex flex-1 flex-col items-center gap-1 py-3 text-sm font-medium text-slate-500 hover:text-slate-900 active:bg-slate-100 min-h-[56px] transition-colors"
|
||||||
|
aria-label="More menu"
|
||||||
|
>
|
||||||
|
<Menu class="h-5 w-5" />
|
||||||
|
<span>More</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
CreditCard,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
|
Settings2,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Shield,
|
||||||
|
FolderOpen,
|
||||||
|
DollarSign,
|
||||||
|
CalendarPlus
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import type { MemberWithDues } from '$lib/types/database';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: MemberWithDues | null;
|
||||||
|
currentPath: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { member, currentPath, collapsed = false, onToggle }: Props = $props();
|
||||||
|
|
||||||
|
const isBoard = $derived(member?.role === 'board' || member?.role === 'admin');
|
||||||
|
const isAdmin = $derived(member?.role === 'admin');
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberNav: NavItem[] = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/profile', label: 'My Profile', icon: User },
|
||||||
|
{ href: '/events', label: 'Events', icon: Calendar },
|
||||||
|
{ href: '/payments', label: 'Payments', icon: CreditCard },
|
||||||
|
{ href: '/documents', label: 'Documents', icon: FolderOpen },
|
||||||
|
{ href: '/settings', label: 'Settings', icon: Settings2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const boardNav: NavItem[] = [
|
||||||
|
{ href: '/board/members', label: 'Members', icon: Users },
|
||||||
|
{ href: '/board/dues', label: 'Dues Management', icon: DollarSign },
|
||||||
|
{ href: '/board/events', label: 'Manage Events', icon: CalendarPlus },
|
||||||
|
{ href: '/board/documents', label: 'Documents', icon: FileText }
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNav: NavItem[] = [
|
||||||
|
{ href: '/admin/members', label: 'User Management', icon: Shield },
|
||||||
|
{ href: '/admin/settings', label: 'Settings', icon: Settings }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === '/dashboard') return currentPath === href;
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="flex h-full flex-col border-r border-slate-200/50 bg-white/80 backdrop-blur-sm transition-all duration-300 motion-reduce:transition-none {collapsed
|
||||||
|
? 'w-16'
|
||||||
|
: 'w-64'}"
|
||||||
|
>
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex h-16 items-center justify-between border-b border-slate-200/50 px-4">
|
||||||
|
{#if !collapsed}
|
||||||
|
<a href="/" class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
src="/MONACOUSA-Flags_376x376.png"
|
||||||
|
alt="Monaco USA"
|
||||||
|
class="h-8 w-8 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold text-slate-900">Monaco USA</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href="/" class="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src="/MONACOUSA-Flags_376x376.png"
|
||||||
|
alt="Monaco USA"
|
||||||
|
class="h-8 w-8 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={onToggle}
|
||||||
|
class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors"
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
{#if collapsed}
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 overflow-y-auto p-3">
|
||||||
|
<!-- Member Navigation -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each memberNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5 flex-shrink-0" />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Board Navigation -->
|
||||||
|
{#if isBoard}
|
||||||
|
<div class="my-4 border-t border-slate-200"></div>
|
||||||
|
{#if !collapsed}
|
||||||
|
<p class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
Board
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each boardNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5 flex-shrink-0" />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Admin Navigation -->
|
||||||
|
{#if isAdmin}
|
||||||
|
<div class="my-4 border-t border-slate-200"></div>
|
||||||
|
{#if !collapsed}
|
||||||
|
<p class="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each adminNav as item}
|
||||||
|
{@const Icon = item.icon}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-monaco-100 text-monaco-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}"
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<Icon class="h-5 w-5 flex-shrink-0" />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- User Section -->
|
||||||
|
<div class="border-t border-slate-200/50 p-3">
|
||||||
|
{#if member && !collapsed}
|
||||||
|
<div class="mb-3 flex items-center gap-3 rounded-lg bg-slate-50 p-2">
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
class="h-9 w-9 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-monaco-100 text-sm font-medium text-monaco-700"
|
||||||
|
>
|
||||||
|
{member.first_name[0]}{member.last_name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<p class="truncate text-sm font-medium text-slate-900">
|
||||||
|
{member.first_name}
|
||||||
|
{member.last_name}
|
||||||
|
</p>
|
||||||
|
<p class="truncate text-xs text-slate-500">{member.member_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if member && collapsed}
|
||||||
|
<div class="mb-3 flex justify-center">
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt={`${member.first_name} ${member.last_name}`}
|
||||||
|
class="h-9 w-9 rounded-full object-cover"
|
||||||
|
title={`${member.first_name} ${member.last_name}`}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-monaco-100 text-sm font-medium text-monaco-700"
|
||||||
|
title={`${member.first_name} ${member.last_name}`}
|
||||||
|
>
|
||||||
|
{member.first_name[0]}{member.last_name[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
title={collapsed ? 'Sign out' : undefined}
|
||||||
|
>
|
||||||
|
<LogOut class="h-5 w-5 flex-shrink-0" />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span>Sign out</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Calendar, ChevronDown, Download, ExternalLink } from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventId: string;
|
||||||
|
eventTitle: string;
|
||||||
|
startDatetime: string;
|
||||||
|
endDatetime: string;
|
||||||
|
location?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
isPublic?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
eventId,
|
||||||
|
eventTitle,
|
||||||
|
startDatetime,
|
||||||
|
endDatetime,
|
||||||
|
location = null,
|
||||||
|
description = null,
|
||||||
|
isPublic = false,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
let buttonRef = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Generate calendar URLs
|
||||||
|
function getGoogleCalendarUrl(): string {
|
||||||
|
const start = new Date(startDatetime);
|
||||||
|
const end = new Date(endDatetime);
|
||||||
|
|
||||||
|
const formatGoogleDate = (date: Date) => {
|
||||||
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'TEMPLATE',
|
||||||
|
text: eventTitle,
|
||||||
|
dates: `${formatGoogleDate(start)}/${formatGoogleDate(end)}`,
|
||||||
|
details: description || '',
|
||||||
|
location: location || '',
|
||||||
|
sf: 'true'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.google.com/calendar/render?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutlookCalendarUrl(): string {
|
||||||
|
const start = new Date(startDatetime);
|
||||||
|
const end = new Date(endDatetime);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
rru: 'addevent',
|
||||||
|
startdt: start.toISOString(),
|
||||||
|
enddt: end.toISOString(),
|
||||||
|
subject: eventTitle,
|
||||||
|
body: description || '',
|
||||||
|
location: location || '',
|
||||||
|
path: '/calendar/action/compose'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcsDownloadUrl(): string {
|
||||||
|
const basePath = isPublic ? '/api/calendar/public/events' : '/api/calendar/events';
|
||||||
|
return `${basePath}/${eventId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadIcs() {
|
||||||
|
window.location.href = getIcsDownloadUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (buttonRef && !buttonRef.contains(event.target as Node)) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={buttonRef} class="relative inline-block {className}">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (isOpen = !isOpen)}
|
||||||
|
class="gap-2"
|
||||||
|
>
|
||||||
|
<Calendar class="h-4 w-4" />
|
||||||
|
Add to Calendar
|
||||||
|
<ChevronDown class="h-3 w-3 transition-transform {isOpen ? 'rotate-180' : ''}" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-slate-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
<a
|
||||||
|
href={getGoogleCalendarUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M19.5 3h-3V1.5h-1.5V3h-6V1.5H7.5V3h-3A1.5 1.5 0 0 0 3 4.5v15A1.5 1.5 0 0 0 4.5 21h15a1.5 1.5 0 0 0 1.5-1.5v-15A1.5 1.5 0 0 0 19.5 3zm0 16.5h-15V7.5h15v12z"
|
||||||
|
/>
|
||||||
|
<path d="M7.5 10.5H6v1.5h1.5v-1.5zm3 0H9v1.5h1.5v-1.5zm3 0H12v1.5h1.5v-1.5zm3 0H15v1.5h1.5v-1.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>Google Calendar</span>
|
||||||
|
<ExternalLink class="ml-auto h-3 w-3 text-slate-400" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={getOutlookCalendarUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Outlook.com</span>
|
||||||
|
<ExternalLink class="ml-auto h-3 w-3 text-slate-400" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="my-1 border-t border-slate-100"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleDownloadIcs}
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
<span>Download .ics file</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="px-4 py-2 text-xs text-slate-500">
|
||||||
|
Use .ics file for Apple Calendar, Outlook desktop, or any calendar app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* CountryFlag component - displays a country flag using SVG images
|
||||||
|
* Uses ISO 3166-1 alpha-2 country codes (e.g., 'US', 'FR', 'MC')
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** ISO 3166-1 alpha-2 country code (e.g., 'US', 'FR', 'MC') */
|
||||||
|
code: string;
|
||||||
|
/** Size of the flag: 'xs' | 'sm' | 'md' | 'lg' | 'xl' */
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
/** Use square (1:1) aspect ratio instead of standard (4:3) */
|
||||||
|
square?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { code, size = 'md', square = false, class: className = '' }: Props = $props();
|
||||||
|
|
||||||
|
// Size mappings in pixels for cleaner rendering
|
||||||
|
const sizes = {
|
||||||
|
xs: { width: 16, height: 12 },
|
||||||
|
sm: { width: 20, height: 15 },
|
||||||
|
md: { width: 24, height: 18 },
|
||||||
|
lg: { width: 32, height: 24 },
|
||||||
|
xl: { width: 40, height: 30 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSize = $derived(sizes[size]);
|
||||||
|
const flagSrc = $derived(`/flags/${code.toLowerCase()}.svg`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={flagSrc}
|
||||||
|
alt="{code} flag"
|
||||||
|
width={currentSize.width}
|
||||||
|
height={square ? currentSize.width : currentSize.height}
|
||||||
|
class="inline-block object-cover {square ? 'rounded-sm' : ''} {className}"
|
||||||
|
style="vertical-align: middle;"
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Combobox } from 'bits-ui';
|
||||||
|
import { countries } from '$lib/utils/countries';
|
||||||
|
import CountryFlag from './CountryFlag.svelte';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import { Check, ChevronDown, Search } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Selected country code */
|
||||||
|
value?: string;
|
||||||
|
/** Callback when selection changes */
|
||||||
|
onValueChange?: (code: string) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the select is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Additional classes */
|
||||||
|
class?: string;
|
||||||
|
/** Name for form submission */
|
||||||
|
name?: string;
|
||||||
|
/** Whether field is required */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Select country...',
|
||||||
|
disabled = false,
|
||||||
|
class: className = '',
|
||||||
|
name,
|
||||||
|
required = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchValue = $state('');
|
||||||
|
let anchorEl = $state<HTMLElement>(null!);
|
||||||
|
|
||||||
|
// Priority countries for Monaco USA members
|
||||||
|
const priorityCountryCodes = ['US', 'MC', 'FR', 'GB', 'IT', 'CH', 'DE', 'ES'];
|
||||||
|
|
||||||
|
// Sort countries with priority ones first
|
||||||
|
const sortedCountries = $derived(() => {
|
||||||
|
const priority = countries.filter(c => priorityCountryCodes.includes(c.code));
|
||||||
|
const others = countries.filter(c => !priorityCountryCodes.includes(c.code));
|
||||||
|
return [...priority, ...others];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter countries based on search
|
||||||
|
const filteredCountries = $derived(
|
||||||
|
searchValue === ''
|
||||||
|
? sortedCountries()
|
||||||
|
: sortedCountries().filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||||
|
c.code.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get selected country
|
||||||
|
const selectedCountry = $derived(countries.find((c) => c.code === value));
|
||||||
|
|
||||||
|
function handleOpenChange(newOpen: boolean) {
|
||||||
|
open = newOpen;
|
||||||
|
if (!newOpen) {
|
||||||
|
searchValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(code: string) {
|
||||||
|
value = code;
|
||||||
|
onValueChange?.(code);
|
||||||
|
open = false;
|
||||||
|
searchValue = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('w-full', className)}>
|
||||||
|
<Combobox.Root
|
||||||
|
type="single"
|
||||||
|
bind:open
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
items={sortedCountries().map((c) => ({ value: c.code, label: c.name }))}
|
||||||
|
bind:value
|
||||||
|
onValueChange={(v) => v && handleSelect(v)}
|
||||||
|
>
|
||||||
|
<div class="relative" bind:this={anchorEl}>
|
||||||
|
<Combobox.Trigger
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 text-sm',
|
||||||
|
'ring-offset-white transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-offset-2',
|
||||||
|
'hover:border-slate-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
{#if selectedCountry}
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<CountryFlag code={selectedCountry.code} size="md" />
|
||||||
|
<span class="truncate">{selectedCountry.name}</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-slate-400">{placeholder}</span>
|
||||||
|
{/if}
|
||||||
|
<ChevronDown
|
||||||
|
class={cn('h-4 w-4 text-slate-400 transition-transform shrink-0', open && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Combobox.Trigger>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Content
|
||||||
|
customAnchor={anchorEl}
|
||||||
|
sideOffset={8}
|
||||||
|
class={cn(
|
||||||
|
'z-[9999] w-[var(--bits-combobox-anchor-width)] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="border-b border-slate-100 p-2">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Combobox.Input
|
||||||
|
class="h-9 w-full rounded-md border border-slate-200 bg-white pl-8 pr-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-monaco-500"
|
||||||
|
placeholder="Search countries..."
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Combobox.Viewport class="max-h-[220px] overflow-y-auto p-1">
|
||||||
|
{#if filteredCountries.length === 0}
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-slate-500">
|
||||||
|
No countries found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCountries as country (country.code)}
|
||||||
|
{@const isSelected = value === country.code}
|
||||||
|
<Combobox.Item
|
||||||
|
value={country.code}
|
||||||
|
label={country.name}
|
||||||
|
class={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-none transition-colors min-h-[44px]',
|
||||||
|
'data-[highlighted]:bg-slate-100',
|
||||||
|
isSelected && 'bg-monaco-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected })}
|
||||||
|
<CountryFlag code={country.code} size="md" />
|
||||||
|
<span class="flex-1 truncate">{country.name}</span>
|
||||||
|
{#if selected}
|
||||||
|
<Check class="h-4 w-4 text-monaco-600 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Combobox.Viewport>
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
|
||||||
|
<!-- Hidden input for form submission -->
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} {value} {required} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { DatePicker } from 'bits-ui';
|
||||||
|
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||||
|
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The selected date value */
|
||||||
|
value?: CalendarDate | undefined;
|
||||||
|
/** Callback when date changes */
|
||||||
|
onValueChange?: (date: CalendarDate | undefined) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the picker is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Minimum allowed date */
|
||||||
|
minValue?: CalendarDate;
|
||||||
|
/** Maximum allowed date */
|
||||||
|
maxValue?: CalendarDate;
|
||||||
|
/** Additional classes for the trigger */
|
||||||
|
class?: string;
|
||||||
|
/** ID for form association */
|
||||||
|
id?: string;
|
||||||
|
/** Name for form submission */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Select a date',
|
||||||
|
disabled = false,
|
||||||
|
minValue,
|
||||||
|
maxValue,
|
||||||
|
class: className = '',
|
||||||
|
id,
|
||||||
|
name
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
function handleValueChange(newValue: CalendarDate | undefined) {
|
||||||
|
value = newValue;
|
||||||
|
onValueChange?.(newValue);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDate = $derived(
|
||||||
|
value
|
||||||
|
? value.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to ISO string for hidden form input
|
||||||
|
const isoDate = $derived(
|
||||||
|
value ? `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')}` : ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DatePicker.Root
|
||||||
|
bind:value
|
||||||
|
bind:open
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
{minValue}
|
||||||
|
{maxValue}
|
||||||
|
>
|
||||||
|
<DatePicker.Input
|
||||||
|
{id}
|
||||||
|
class={cn(
|
||||||
|
'flex h-11 w-full items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white transition-colors',
|
||||||
|
'focus-within:outline-none focus-within:ring-2 focus-within:ring-monaco-500 focus-within:ring-offset-2',
|
||||||
|
'hover:border-slate-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet children({ segments })}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each segments as { part, value: segValue }}
|
||||||
|
{#if part === 'literal'}
|
||||||
|
<span class="text-slate-400">{segValue}</span>
|
||||||
|
{:else}
|
||||||
|
<DatePicker.Segment
|
||||||
|
{part}
|
||||||
|
class={cn(
|
||||||
|
'rounded px-1 py-0.5 tabular-nums',
|
||||||
|
'focus:outline-none focus:bg-monaco-100 focus:text-monaco-900',
|
||||||
|
'data-[placeholder]:text-slate-400',
|
||||||
|
'hover:bg-slate-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{segValue}
|
||||||
|
</DatePicker.Segment>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<DatePicker.Trigger
|
||||||
|
class={cn(
|
||||||
|
'rounded p-2 transition-colors',
|
||||||
|
'hover:bg-slate-100',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-offset-2',
|
||||||
|
disabled && 'pointer-events-none'
|
||||||
|
)}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<Calendar class="h-4 w-4 text-slate-500" />
|
||||||
|
</DatePicker.Trigger>
|
||||||
|
{/snippet}
|
||||||
|
</DatePicker.Input>
|
||||||
|
|
||||||
|
<DatePicker.Content
|
||||||
|
class="z-50 rounded-xl border border-slate-200 bg-white p-3 sm:p-4 shadow-xl w-[calc(100vw-2rem)] sm:w-auto max-w-[320px]"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DatePicker.Calendar>
|
||||||
|
{#snippet children({ months, weekdays })}
|
||||||
|
<DatePicker.Header class="mb-4 flex items-center justify-between">
|
||||||
|
<DatePicker.PrevButton
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 w-10 sm:h-8 sm:w-8 items-center justify-center rounded-lg transition-colors',
|
||||||
|
'hover:bg-slate-100 active:bg-slate-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft class="h-5 w-5 sm:h-4 sm:w-4" />
|
||||||
|
</DatePicker.PrevButton>
|
||||||
|
<DatePicker.Heading class="text-sm font-semibold text-slate-900" />
|
||||||
|
<DatePicker.NextButton
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 w-10 sm:h-8 sm:w-8 items-center justify-center rounded-lg transition-colors',
|
||||||
|
'hover:bg-slate-100 active:bg-slate-200',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-5 w-5 sm:h-4 sm:w-4" />
|
||||||
|
</DatePicker.NextButton>
|
||||||
|
</DatePicker.Header>
|
||||||
|
|
||||||
|
{#each months as month}
|
||||||
|
<DatePicker.Grid class="w-full border-collapse">
|
||||||
|
<DatePicker.GridHead>
|
||||||
|
<DatePicker.GridRow class="flex w-full">
|
||||||
|
{#each weekdays as day}
|
||||||
|
<DatePicker.HeadCell
|
||||||
|
class="w-10 sm:w-9 flex-1 text-center text-xs font-medium text-slate-500"
|
||||||
|
>
|
||||||
|
{day.slice(0, 2)}
|
||||||
|
</DatePicker.HeadCell>
|
||||||
|
{/each}
|
||||||
|
</DatePicker.GridRow>
|
||||||
|
</DatePicker.GridHead>
|
||||||
|
<DatePicker.GridBody>
|
||||||
|
{#each month.weeks as weekDates}
|
||||||
|
<DatePicker.GridRow class="flex w-full">
|
||||||
|
{#each weekDates as date}
|
||||||
|
<DatePicker.Cell {date} month={month.value} class="flex-1 p-0.5">
|
||||||
|
<DatePicker.Day
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 w-10 sm:h-9 sm:w-9 items-center justify-center rounded-lg text-sm transition-colors min-h-[40px]',
|
||||||
|
'hover:bg-slate-100',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-inset',
|
||||||
|
'data-[today]:font-bold data-[today]:text-monaco-600',
|
||||||
|
'data-[selected]:bg-monaco-600 data-[selected]:text-white data-[selected]:hover:bg-monaco-700',
|
||||||
|
'data-[outside-month]:text-slate-300 data-[outside-month]:pointer-events-none',
|
||||||
|
'data-[disabled]:text-slate-300 data-[disabled]:pointer-events-none',
|
||||||
|
'data-[unavailable]:text-slate-300 data-[unavailable]:line-through data-[unavailable]:pointer-events-none'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DatePicker.Cell>
|
||||||
|
{/each}
|
||||||
|
</DatePicker.GridRow>
|
||||||
|
{/each}
|
||||||
|
</DatePicker.GridBody>
|
||||||
|
</DatePicker.Grid>
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
</DatePicker.Calendar>
|
||||||
|
</DatePicker.Content>
|
||||||
|
</DatePicker.Root>
|
||||||
|
|
||||||
|
<!-- Hidden input for form submission -->
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} value={isoDate} />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Combobox } from 'bits-ui';
|
||||||
|
import { countries } from '$lib/utils/countries';
|
||||||
|
import CountryFlag from './CountryFlag.svelte';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import { Check, ChevronDown, X, Search } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Selected country codes */
|
||||||
|
value?: string[];
|
||||||
|
/** Callback when selection changes */
|
||||||
|
onValueChange?: (codes: string[]) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the select is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Maximum number of selections allowed (0 = unlimited) */
|
||||||
|
maxSelections?: number;
|
||||||
|
/** Additional classes */
|
||||||
|
class?: string;
|
||||||
|
/** Name for form submission */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable([]),
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Select nationalities...',
|
||||||
|
disabled = false,
|
||||||
|
maxSelections = 0,
|
||||||
|
class: className = '',
|
||||||
|
name
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchValue = $state('');
|
||||||
|
let anchorEl = $state<HTMLElement>(null!);
|
||||||
|
|
||||||
|
// Convert countries to combobox items format
|
||||||
|
const countryItems = countries.map((c) => ({
|
||||||
|
value: c.code,
|
||||||
|
label: c.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter countries based on search
|
||||||
|
const filteredCountries = $derived(
|
||||||
|
searchValue === ''
|
||||||
|
? countries
|
||||||
|
: countries.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||||
|
c.code.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function getCountry(code: string) {
|
||||||
|
return countries.find((c) => c.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelection(code: string) {
|
||||||
|
if (value.includes(code)) {
|
||||||
|
value = value.filter((v) => v !== code);
|
||||||
|
} else if (maxSelections === 0 || value.length < maxSelections) {
|
||||||
|
value = [...value, code];
|
||||||
|
}
|
||||||
|
onValueChange?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelection(code: string) {
|
||||||
|
value = value.filter((v) => v !== code);
|
||||||
|
onValueChange?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(newOpen: boolean) {
|
||||||
|
open = newOpen;
|
||||||
|
if (!newOpen) {
|
||||||
|
searchValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('space-y-2', className)}>
|
||||||
|
<!-- Selected Items -->
|
||||||
|
{#if value.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each value as code}
|
||||||
|
{@const country = getCountry(code)}
|
||||||
|
{#if country}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full bg-monaco-100 py-1 pl-2 pr-1 text-sm text-monaco-800"
|
||||||
|
>
|
||||||
|
<CountryFlag {code} size="sm" />
|
||||||
|
<span class="max-w-[120px] truncate">{country.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeSelection(code)}
|
||||||
|
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-monaco-200"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<Combobox.Root
|
||||||
|
type="multiple"
|
||||||
|
bind:open
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
items={countryItems}
|
||||||
|
bind:value
|
||||||
|
onValueChange={(v) => onValueChange?.(v)}
|
||||||
|
>
|
||||||
|
<div class="relative" bind:this={anchorEl}>
|
||||||
|
<Combobox.Trigger
|
||||||
|
class={cn(
|
||||||
|
'flex h-11 w-full items-center rounded-lg border border-slate-200 bg-white text-sm',
|
||||||
|
'ring-offset-white transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-offset-2',
|
||||||
|
'hover:border-slate-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Combobox.Input
|
||||||
|
class={cn(
|
||||||
|
'h-full w-full bg-transparent pl-10 pr-10 text-sm outline-none',
|
||||||
|
'placeholder:text-slate-400'
|
||||||
|
)}
|
||||||
|
placeholder={value.length > 0 ? 'Add more...' : placeholder}
|
||||||
|
{disabled}
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = e.currentTarget.value;
|
||||||
|
if (!open) open = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ChevronDown
|
||||||
|
class={cn('pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500 transition-transform', open && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Combobox.Trigger>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Content
|
||||||
|
customAnchor={anchorEl}
|
||||||
|
sideOffset={8}
|
||||||
|
class={cn(
|
||||||
|
'z-[9999] w-[var(--bits-combobox-anchor-width)] max-h-[220px] sm:max-h-[300px] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Combobox.Viewport class="max-h-[200px] sm:max-h-[280px] overflow-y-auto p-1">
|
||||||
|
{#if filteredCountries.length === 0}
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-slate-500">
|
||||||
|
No countries found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCountries as country (country.code)}
|
||||||
|
{@const isSelected = value.includes(country.code)}
|
||||||
|
{@const isDisabled = !isSelected && maxSelections > 0 && value.length >= maxSelections}
|
||||||
|
<Combobox.Item
|
||||||
|
value={country.code}
|
||||||
|
label={country.name}
|
||||||
|
disabled={isDisabled}
|
||||||
|
class={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-3 rounded-lg px-3 py-3 sm:py-2.5 text-sm outline-none transition-colors min-h-[44px]',
|
||||||
|
'data-[highlighted]:bg-slate-100',
|
||||||
|
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||||
|
isSelected && 'bg-monaco-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected })}
|
||||||
|
<CountryFlag code={country.code} size="md" />
|
||||||
|
<span class="flex-1">{country.name}</span>
|
||||||
|
<span class="text-xs text-slate-400">{country.code}</span>
|
||||||
|
{#if selected}
|
||||||
|
<Check class="h-4 w-4 text-monaco-600" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Combobox.Viewport>
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
|
||||||
|
<!-- Hidden input for form submission -->
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} value={value.join(',')} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Combobox } from 'bits-ui';
|
||||||
|
import { phoneCountries, type PhoneCountryCode } from '$lib/utils/phoneCountries';
|
||||||
|
import CountryFlag from './CountryFlag.svelte';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import { ChevronDown, Search } from 'lucide-svelte';
|
||||||
|
import { AsYouType, type CountryCode } from 'libphonenumber-js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The full phone number value (E.164 format for form submission) */
|
||||||
|
value?: string;
|
||||||
|
/** The display value (formatted for the user) */
|
||||||
|
displayValue?: string;
|
||||||
|
/** Selected country code */
|
||||||
|
countryCode?: PhoneCountryCode;
|
||||||
|
/** Callback when value changes */
|
||||||
|
onValueChange?: (fullNumber: string, nationalNumber: string, countryCode: string) => void;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the input is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Additional classes */
|
||||||
|
class?: string;
|
||||||
|
/** Name for form submission */
|
||||||
|
name?: string;
|
||||||
|
/** Whether field is required */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
displayValue = $bindable(''),
|
||||||
|
countryCode = $bindable<PhoneCountryCode>('US'),
|
||||||
|
onValueChange,
|
||||||
|
placeholder = 'Phone number',
|
||||||
|
disabled = false,
|
||||||
|
class: className = '',
|
||||||
|
name,
|
||||||
|
required = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let searchValue = $state('');
|
||||||
|
let inputValue = $state('');
|
||||||
|
let anchorEl = $state<HTMLElement>(null!);
|
||||||
|
|
||||||
|
// Get selected country
|
||||||
|
const selectedCountry = $derived(phoneCountries.find((c) => c.code === countryCode) || phoneCountries[0]);
|
||||||
|
|
||||||
|
// Filter countries based on search
|
||||||
|
const filteredCountries = $derived(
|
||||||
|
searchValue === ''
|
||||||
|
? phoneCountries
|
||||||
|
: phoneCountries.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||||
|
c.code.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||||
|
c.dialCode.includes(searchValue)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format phone number as user types
|
||||||
|
function formatPhoneNumber(input: string, country: string): string {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
// Remove all non-digit characters for processing
|
||||||
|
const digitsOnly = input.replace(/\D/g, '');
|
||||||
|
if (!digitsOnly) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new AsYouType(country as CountryCode);
|
||||||
|
return formatter.input(digitsOnly);
|
||||||
|
} catch {
|
||||||
|
// If formatting fails, return digits with basic spacing
|
||||||
|
return digitsOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get E.164 format for form submission
|
||||||
|
function getE164Number(input: string, country: string): string {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const digitsOnly = input.replace(/\D/g, '');
|
||||||
|
if (!digitsOnly) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new AsYouType(country as CountryCode);
|
||||||
|
formatter.input(digitsOnly);
|
||||||
|
const number = formatter.getNumber();
|
||||||
|
return number?.format('E.164') || `${selectedCountry.dialCode}${digitsOnly}`;
|
||||||
|
} catch {
|
||||||
|
return `${selectedCountry.dialCode}${digitsOnly}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const rawValue = target.value;
|
||||||
|
|
||||||
|
// Format the display value
|
||||||
|
const formatted = formatPhoneNumber(rawValue, countryCode);
|
||||||
|
inputValue = formatted;
|
||||||
|
displayValue = formatted;
|
||||||
|
|
||||||
|
// Get E.164 for storage
|
||||||
|
const e164 = getE164Number(rawValue, countryCode);
|
||||||
|
value = e164;
|
||||||
|
|
||||||
|
// Notify parent
|
||||||
|
const digitsOnly = rawValue.replace(/\D/g, '');
|
||||||
|
onValueChange?.(e164, digitsOnly, countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCountryChange(newCode: string) {
|
||||||
|
countryCode = newCode as PhoneCountryCode;
|
||||||
|
open = false;
|
||||||
|
searchValue = '';
|
||||||
|
|
||||||
|
// Reformat existing number with new country
|
||||||
|
if (inputValue) {
|
||||||
|
const formatted = formatPhoneNumber(inputValue, newCode);
|
||||||
|
inputValue = formatted;
|
||||||
|
displayValue = formatted;
|
||||||
|
|
||||||
|
const e164 = getE164Number(inputValue, newCode);
|
||||||
|
value = e164;
|
||||||
|
|
||||||
|
const digitsOnly = inputValue.replace(/\D/g, '');
|
||||||
|
onValueChange?.(e164, digitsOnly, newCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(newOpen: boolean) {
|
||||||
|
open = newOpen;
|
||||||
|
if (!newOpen) {
|
||||||
|
searchValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize display value from value prop
|
||||||
|
$effect(() => {
|
||||||
|
if (value && !inputValue) {
|
||||||
|
// Try to extract national number from E.164
|
||||||
|
const digitsOnly = value.replace(/\D/g, '');
|
||||||
|
if (digitsOnly) {
|
||||||
|
inputValue = formatPhoneNumber(digitsOnly, countryCode);
|
||||||
|
displayValue = inputValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex gap-2', className)}>
|
||||||
|
<!-- Country Code Dropdown -->
|
||||||
|
<Combobox.Root
|
||||||
|
type="single"
|
||||||
|
bind:open
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
items={phoneCountries.map((c) => ({ value: c.code, label: c.name }))}
|
||||||
|
bind:value={countryCode}
|
||||||
|
onValueChange={(v) => v && handleCountryChange(v)}
|
||||||
|
>
|
||||||
|
<div class="relative" bind:this={anchorEl}>
|
||||||
|
<Combobox.Trigger
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2 text-sm',
|
||||||
|
'ring-offset-white transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-offset-2',
|
||||||
|
'hover:border-slate-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<CountryFlag code={countryCode} size="md" />
|
||||||
|
<span class="text-slate-600">{selectedCountry.dialCode}</span>
|
||||||
|
<ChevronDown
|
||||||
|
class={cn('h-3.5 w-3.5 text-slate-400 transition-transform', open && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Combobox.Trigger>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Combobox.Portal>
|
||||||
|
<Combobox.Content
|
||||||
|
customAnchor={anchorEl}
|
||||||
|
sideOffset={8}
|
||||||
|
class={cn(
|
||||||
|
'z-[9999] w-72 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="border-b border-slate-100 p-2">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Combobox.Input
|
||||||
|
class="h-9 w-full rounded-md border border-slate-200 bg-white pl-8 pr-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-monaco-500"
|
||||||
|
placeholder="Search countries..."
|
||||||
|
oninput={(e) => {
|
||||||
|
searchValue = e.currentTarget.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Combobox.Viewport class="max-h-[220px] overflow-y-auto p-1">
|
||||||
|
{#if filteredCountries.length === 0}
|
||||||
|
<div class="px-3 py-6 text-center text-sm text-slate-500">
|
||||||
|
No countries found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCountries as country (country.code)}
|
||||||
|
{@const isSelected = countryCode === country.code}
|
||||||
|
<Combobox.Item
|
||||||
|
value={country.code}
|
||||||
|
label={country.name}
|
||||||
|
class={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-3 rounded-lg px-3 py-2.5 text-sm outline-none transition-colors min-h-[44px]',
|
||||||
|
'data-[highlighted]:bg-slate-100',
|
||||||
|
isSelected && 'bg-monaco-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected })}
|
||||||
|
<CountryFlag code={country.code} size="md" />
|
||||||
|
<span class="flex-1 truncate">{country.name}</span>
|
||||||
|
<span class="text-xs font-medium text-slate-500">{country.dialCode}</span>
|
||||||
|
{/snippet}
|
||||||
|
</Combobox.Item>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</Combobox.Viewport>
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
|
||||||
|
<!-- Phone Number Input -->
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class={cn(
|
||||||
|
'flex h-10 flex-1 rounded-lg border border-slate-200 bg-white px-3 text-sm',
|
||||||
|
'ring-offset-white transition-colors',
|
||||||
|
'placeholder:text-slate-400',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-monaco-500 focus:ring-offset-2',
|
||||||
|
'hover:border-slate-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
{placeholder}
|
||||||
|
{disabled}
|
||||||
|
{required}
|
||||||
|
value={inputValue}
|
||||||
|
oninput={handleInput}
|
||||||
|
autocomplete="tel-national"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Hidden input for form submission with E.164 format -->
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} value={value} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
monaco: 'bg-monaco-600 text-white shadow hover:bg-monaco-700',
|
||||||
|
'monaco-outline': 'border border-monaco-600 text-monaco-600 hover:bg-monaco-50',
|
||||||
|
glass: 'glass text-foreground hover:bg-white/80'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
xl: 'h-12 rounded-lg px-10 text-base',
|
||||||
|
icon: 'h-10 w-10'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
class?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
disabled = false,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps & { children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('p-6 pt-0', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class={cn('text-sm text-muted-foreground', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex items-center p-6 pt-0', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</h3>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Root from './input.svelte';
|
||||||
|
|
||||||
|
export { Root, Root as Input };
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
value = $bindable(''),
|
||||||
|
type = 'text',
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
class?: string;
|
||||||
|
value?: string;
|
||||||
|
type?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
class={cn(
|
||||||
|
'flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Root from './label.svelte';
|
||||||
|
|
||||||
|
export { Root, Root as Label };
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: { class?: string; children?: import('svelte').Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</label>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Monaco USA Portal 2026 - Library exports
|
||||||
|
export * from './utils';
|
||||||
|
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
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<string, unknown>,
|
||||||
|
requestInfo?: { ip?: string; userAgent?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
requestInfo?: { ip?: string; userAgent?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
requestInfo?: { ip?: string; userAgent?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
requestInfo?: { ip?: string; userAgent?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
requestInfo?: { ip?: string; userAgent?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -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<DuesSettings> {
|
||||||
|
const { data: settings } = await supabaseAdmin
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'dues');
|
||||||
|
|
||||||
|
const config: Record<string, any> = {};
|
||||||
|
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<MemberWithDues[]> {
|
||||||
|
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<MemberWithDues[]> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<DuesReminderResult> {
|
||||||
|
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<DuesAnalytics> {
|
||||||
|
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<string, { amount: number; count: number }>();
|
||||||
|
|
||||||
|
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<OnboardingMember[]> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<DuesReminderResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -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<SmtpConfig | null> {
|
||||||
|
const { data: settings } = await supabaseAdmin
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'email');
|
||||||
|
|
||||||
|
if (!settings || settings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings) {
|
||||||
|
// Parse the value - it might be JSON stringified or plain
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Remove surrounding quotes if present
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
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<Transporter | null> {
|
||||||
|
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<string, string>,
|
||||||
|
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<string, string> = {
|
||||||
|
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[^>]*>([^<]+)<\/h2>/i);
|
||||||
|
if (h2Match) {
|
||||||
|
emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if template already has full HTML wrapper (legacy templates)
|
||||||
|
const hasFullWrapper = bodyContent.includes('<!DOCTYPE') || bodyContent.includes('<html');
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
if (hasFullWrapper) {
|
||||||
|
// Legacy template with full HTML - use as-is but inject background image
|
||||||
|
// Replace old gradient-only background with new background image pattern
|
||||||
|
html = bodyContent.replace(
|
||||||
|
/<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient\([^)]+\); background-color: #0f172a;">/g,
|
||||||
|
`<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${EMAIL_BACKGROUND_IMAGE_URL}'); background-size: cover; background-position: center; background-color: #0f172a;">`
|
||||||
|
);
|
||||||
|
} 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 = `
|
||||||
|
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">This is a test email from your Monaco USA Portal.</p>
|
||||||
|
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #166534; font-size: 14px; font-weight: 600;">✓ Configuration Verified</p>
|
||||||
|
<p style="margin: 0; color: #334155; font-size: 14px;">Your SMTP settings are working correctly!</p>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0; color: #64748b; font-size: 12px;">Sent at ${new Date().toLocaleString()}</p>`;
|
||||||
|
|
||||||
|
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 `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td { font-family: Arial, sans-serif !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="t">
|
||||||
|
<v:fill type="tile" src="${bgImageUrl}" color="#0f172a"/>
|
||||||
|
</v:background>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${bgImageUrl}'); background-size: cover; background-position: center; background-color: #0f172a;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Gradient overlay matching login screen: from-slate-900/80 via-slate-900/60 to-monaco-900/70 -->
|
||||||
|
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.8) 0%, rgba(15, 23, 42, 0.6) 50%, rgba(127, 29, 29, 0.7) 100%);">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 30px;">
|
||||||
|
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||||
|
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||||
|
</div>
|
||||||
|
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px; text-align: center;">${options.title}</h2>
|
||||||
|
<div style="text-align: left;">${options.content}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top: 24px;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
@ -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<EventReminderSettings> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<EventNeedingReminder[]> {
|
||||||
|
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<string, string> = {
|
||||||
|
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<EventReminderResult> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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()}`;
|
||||||
|
}
|
||||||
|
|
@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated request to the Poste API
|
||||||
|
*/
|
||||||
|
async function makeRequest<T>(
|
||||||
|
config: PosteConfig,
|
||||||
|
method: string,
|
||||||
|
endpoint: string,
|
||||||
|
body?: Record<string, unknown>
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
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<Mailbox>(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<Mailbox>(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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<Mailbox>(
|
||||||
|
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<void>(
|
||||||
|
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<void>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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<S3Config | null> {
|
||||||
|
// 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<string, any> = {};
|
||||||
|
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<S3Client | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<UploadResult> {
|
||||||
|
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<UploadResult> {
|
||||||
|
// 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<string> {
|
||||||
|
// 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<typeof import('@supabase/supabase-js').createClient>
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
// 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<string | null> {
|
||||||
|
// 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<UploadResult> {
|
||||||
|
// 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<string | null> {
|
||||||
|
// 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<UploadResult> {
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<Database>(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<Database>(
|
||||||
|
SERVER_SUPABASE_URL,
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||||
|
}
|
||||||
|
|
@ -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<string, never>;
|
||||||
|
Enums: Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
@ -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 || '';
|
||||||
|
}
|
||||||
|
|
@ -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<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in browser
|
||||||
|
*/
|
||||||
|
export const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
@ -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 || '';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
// Error details with friendly messages
|
||||||
|
const errorMessages: Record<number, { title: string; message: string; icon: string; color: string }> = {
|
||||||
|
400: {
|
||||||
|
title: 'Bad Request',
|
||||||
|
message: "Something went wrong with your request. Please check and try again.",
|
||||||
|
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||||
|
color: 'amber'
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
title: 'Session Expired',
|
||||||
|
message: 'Your session has expired. Please sign in again to continue.',
|
||||||
|
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||||
|
color: 'blue'
|
||||||
|
},
|
||||||
|
403: {
|
||||||
|
title: 'Access Denied',
|
||||||
|
message: "You don't have permission to access this resource. Contact an administrator if you believe this is an error.",
|
||||||
|
icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
||||||
|
color: 'red'
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
title: 'Page Not Found',
|
||||||
|
message: "We couldn't find the page you're looking for. It may have been moved or deleted.",
|
||||||
|
icon: 'M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
color: 'slate'
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
title: 'Server Error',
|
||||||
|
message: 'Something went wrong on our end. Our team has been notified. Please try again later.',
|
||||||
|
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
color: 'red'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = $derived($page.status);
|
||||||
|
const errorInfo = $derived(
|
||||||
|
errorMessages[status] || {
|
||||||
|
title: 'Something Went Wrong',
|
||||||
|
message: $page.error?.message || 'An unexpected error occurred. Please try again.',
|
||||||
|
icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
color: 'slate'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const colorClasses = $derived({
|
||||||
|
red: {
|
||||||
|
bg: 'bg-gradient-to-br from-red-50 to-red-100',
|
||||||
|
icon: 'text-red-500',
|
||||||
|
border: 'border-red-200'
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: 'bg-gradient-to-br from-amber-50 to-amber-100',
|
||||||
|
icon: 'text-amber-500',
|
||||||
|
border: 'border-amber-200'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
bg: 'bg-gradient-to-br from-blue-50 to-blue-100',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
border: 'border-blue-200'
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
bg: 'bg-gradient-to-br from-slate-50 to-slate-100',
|
||||||
|
icon: 'text-slate-500',
|
||||||
|
border: 'border-slate-200'
|
||||||
|
}
|
||||||
|
}[errorInfo.color]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{status} - {errorInfo.title} | Monaco USA</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/dashboard" class="inline-block">
|
||||||
|
<img
|
||||||
|
src="/MONACOUSA-Flags_376x376.png"
|
||||||
|
alt="Monaco USA"
|
||||||
|
class="h-16 w-16 rounded-xl object-contain shadow-md"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Card -->
|
||||||
|
<div
|
||||||
|
class="w-full max-w-lg rounded-2xl border border-slate-200/60 bg-white/80 p-4 sm:p-6 md:p-8 text-center shadow-lg backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<!-- Error Icon -->
|
||||||
|
<div class="mx-auto mb-4 sm:mb-6 flex h-16 w-16 sm:h-24 sm:w-24 items-center justify-center rounded-full {colorClasses.bg}">
|
||||||
|
<svg
|
||||||
|
class="h-8 w-8 sm:h-12 sm:w-12 {colorClasses.icon}"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d={errorInfo.icon} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Code Badge -->
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-3 sm:mb-4 inline-flex items-center rounded-full border {colorClasses.border} bg-white px-3 sm:px-4 py-1 sm:py-1.5"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-slate-500">Error {status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Title -->
|
||||||
|
<h1 class="mb-3 text-2xl font-bold text-slate-900">{errorInfo.title}</h1>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
<p class="mb-8 text-slate-600">{errorInfo.message}</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||||
|
<Button variant="monaco" size="lg" href="/dashboard">
|
||||||
|
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" size="lg" onclick={() => history.back()}>
|
||||||
|
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if status === 401}
|
||||||
|
<Button variant="outline" size="lg" href="/login">
|
||||||
|
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Text -->
|
||||||
|
<p class="mt-8 text-sm text-slate-500">
|
||||||
|
Need assistance?
|
||||||
|
<a href="mailto:support@monacousa.org" class="text-monaco-600 hover:text-monaco-700">
|
||||||
|
Contact support
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Technical Details (collapsed by default) -->
|
||||||
|
{#if $page.error?.message && import.meta.env.DEV}
|
||||||
|
<details class="mt-6 w-full max-w-lg">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-400 hover:text-slate-600">
|
||||||
|
Technical details
|
||||||
|
</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-2 overflow-auto rounded-lg bg-slate-100 p-4 text-xs text-slate-600">{$page.error.message}</pre>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { Sidebar, Header, MobileNav, MobileMenu } from '$lib/components/layout';
|
||||||
|
import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
|
// Get page title from route data or default
|
||||||
|
const pageTitle = $derived(getPageTitle($page.url.pathname));
|
||||||
|
|
||||||
|
function getPageTitle(path: string): string {
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
'/dashboard': 'Dashboard',
|
||||||
|
'/profile': 'My Profile',
|
||||||
|
'/events': 'Events',
|
||||||
|
'/payments': 'Payments',
|
||||||
|
'/documents': 'Documents',
|
||||||
|
'/board/members': 'Members',
|
||||||
|
'/board/dues': 'Dues Management',
|
||||||
|
'/board/events': 'Manage Events',
|
||||||
|
'/admin/users': 'User Management',
|
||||||
|
'/admin/settings': 'Settings'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [route, title] of Object.entries(titles)) {
|
||||||
|
if (path.startsWith(route)) return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Monaco USA';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarCollapsed = !sidebarCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMobileMenu() {
|
||||||
|
mobileMenuOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileMenu() {
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
|
||||||
|
<!-- Desktop Sidebar -->
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<Sidebar
|
||||||
|
member={data.member}
|
||||||
|
currentPath={$page.url.pathname}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggle={toggleSidebar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<Header member={data.member} title={pageTitle} onMenuToggle={openMobileMenu} />
|
||||||
|
|
||||||
|
<!-- Email Verification Banner -->
|
||||||
|
{#if !data.emailVerified}
|
||||||
|
<EmailVerificationBanner email={data.member.email} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<main class="flex-1 overflow-y-auto pb-20 lg:pb-0">
|
||||||
|
<div class="container mx-auto max-w-7xl p-4 lg:p-6">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Bottom Navigation -->
|
||||||
|
<MobileNav member={data.member} currentPath={$page.url.pathname} onMenuOpen={openMobileMenu} />
|
||||||
|
|
||||||
|
<!-- Mobile Menu Overlay -->
|
||||||
|
<MobileMenu
|
||||||
|
member={data.member}
|
||||||
|
currentPath={$page.url.pathname}
|
||||||
|
open={mobileMenuOpen}
|
||||||
|
onClose={closeMobileMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
@ -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 {};
|
||||||
|
};
|
||||||
|
|
@ -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) || []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
Activity,
|
||||||
|
FileText
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const { memberStats, revenueStats, upcomingEvents, recentPayments, auditLogs, overdueMembers } = data;
|
||||||
|
|
||||||
|
// Calculate revenue trend
|
||||||
|
const revenueTrend = revenueStats.lastMonth > 0
|
||||||
|
? ((revenueStats.thisMonth - revenueStats.lastMonth) / revenueStats.lastMonth * 100).toFixed(1)
|
||||||
|
: 0;
|
||||||
|
const revenueUp = Number(revenueTrend) >= 0;
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD'
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAuditAction(action: string): string {
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
'member.create': 'Created member',
|
||||||
|
'member.update': 'Updated member',
|
||||||
|
'member.delete': 'Deleted member',
|
||||||
|
'member.role_change': 'Changed role',
|
||||||
|
'member.status_change': 'Changed status',
|
||||||
|
'member.invite': 'Invited member',
|
||||||
|
'event.create': 'Created event',
|
||||||
|
'event.update': 'Updated event',
|
||||||
|
'event.delete': 'Deleted event',
|
||||||
|
'payment.record': 'Recorded payment',
|
||||||
|
'document.upload': 'Uploaded document',
|
||||||
|
'document.delete': 'Deleted document',
|
||||||
|
'settings.update': 'Updated settings'
|
||||||
|
};
|
||||||
|
return actionMap[action] || action;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Admin Dashboard | Monaco USA</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Admin Dashboard</h1>
|
||||||
|
<p class="text-slate-500">Overview of Monaco USA portal activity and metrics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- Total Members -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">Total Members</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-slate-900">{memberStats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<Users class="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-2 text-xs">
|
||||||
|
<span class="rounded bg-purple-100 px-2 py-0.5 text-purple-700">{memberStats.byRole.admin} Admin</span>
|
||||||
|
<span class="rounded bg-indigo-100 px-2 py-0.5 text-indigo-700">{memberStats.byRole.board} Board</span>
|
||||||
|
<span class="rounded bg-slate-100 px-2 py-0.5 text-slate-700">{memberStats.byRole.member} Members</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue This Month -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">Revenue This Month</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-slate-900">{formatCurrency(revenueStats.thisMonth)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<DollarSign class="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-1 text-sm">
|
||||||
|
{#if revenueUp}
|
||||||
|
<TrendingUp class="h-4 w-4 text-green-600" />
|
||||||
|
<span class="text-green-600">+{revenueTrend}%</span>
|
||||||
|
{:else}
|
||||||
|
<TrendingDown class="h-4 w-4 text-red-600" />
|
||||||
|
<span class="text-red-600">{revenueTrend}%</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-slate-500">vs last month</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dues Status -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">Dues Status</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-green-600">{memberStats.byDuesStatus.current}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<AlertCircle class="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-2 text-xs">
|
||||||
|
<span class="rounded bg-green-100 px-2 py-0.5 text-green-700">Current</span>
|
||||||
|
<span class="rounded bg-amber-100 px-2 py-0.5 text-amber-700">{memberStats.byDuesStatus.due_soon} Due Soon</span>
|
||||||
|
<span class="rounded bg-red-100 px-2 py-0.5 text-red-700">{memberStats.byDuesStatus.overdue} Overdue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Events -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">Upcoming Events</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold text-slate-900">{upcomingEvents.length}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-monaco-100">
|
||||||
|
<Calendar class="h-6 w-6 text-monaco-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-slate-500">Next 30 days</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<!-- Recent Payments -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-slate-900">Recent Payments</h2>
|
||||||
|
<a href="/board/dues" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recentPayments.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each recentPayments as payment}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-slate-50/50 p-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">
|
||||||
|
{payment.member?.first_name} {payment.member?.last_name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">{formatDate(payment.payment_date)}</p>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-green-600">{formatCurrency(payment.amount)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-slate-500">No recent payments</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Events -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-slate-900">Upcoming Events</h2>
|
||||||
|
<a href="/board/events" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if upcomingEvents.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each upcomingEvents as event}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-slate-50/50 p-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">{event.title}</p>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
{formatDateTime(event.start_datetime)}
|
||||||
|
{#if event.location}
|
||||||
|
| {event.location}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-sm font-medium text-slate-900">{event.total_attendees}</span>
|
||||||
|
{#if event.max_attendees}
|
||||||
|
<span class="text-slate-500">/{event.max_attendees}</span>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-slate-500">attendees</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-slate-500">No upcoming events</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overdue Members -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-slate-900">Overdue Dues</h2>
|
||||||
|
<a href="/board/dues?status=overdue" class="text-sm text-monaco-600 hover:underline">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if overdueMembers.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each overdueMembers as member}
|
||||||
|
<div class="flex items-center justify-between rounded-lg bg-red-50/50 p-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">
|
||||||
|
{member.first_name} {member.last_name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-sm font-medium text-red-600">
|
||||||
|
{member.days_overdue} days
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-slate-500">overdue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-slate-500">
|
||||||
|
<Shield class="mb-2 h-8 w-8" />
|
||||||
|
<p>All members are current!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity (Audit Log) -->
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-slate-900">Recent Activity</h2>
|
||||||
|
<Activity class="h-5 w-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if auditLogs.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each auditLogs.slice(0, 8) as log}
|
||||||
|
<div class="flex items-start gap-3 text-sm">
|
||||||
|
<div class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
{#if log.action.startsWith('member')}
|
||||||
|
<Users class="h-3 w-3 text-slate-500" />
|
||||||
|
{:else if log.action.startsWith('event')}
|
||||||
|
<Calendar class="h-3 w-3 text-slate-500" />
|
||||||
|
{:else if log.action.startsWith('payment')}
|
||||||
|
<DollarSign class="h-3 w-3 text-slate-500" />
|
||||||
|
{:else if log.action.startsWith('document')}
|
||||||
|
<FileText class="h-3 w-3 text-slate-500" />
|
||||||
|
{:else}
|
||||||
|
<Clock class="h-3 w-3 text-slate-500" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-slate-900 truncate">
|
||||||
|
{formatAuditAction(log.action)}
|
||||||
|
{#if log.details?.target_email}
|
||||||
|
<span class="text-slate-500">({log.details.target_email})</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
{log.user_email || 'System'} · {formatDateTime(log.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-slate-500">No recent activity</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
X,
|
||||||
|
Save,
|
||||||
|
Eye,
|
||||||
|
ArrowLeft,
|
||||||
|
Search,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
FileText,
|
||||||
|
Variable
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
interface EmailTemplate {
|
||||||
|
template_key: string;
|
||||||
|
template_name: string;
|
||||||
|
category: string;
|
||||||
|
subject: string;
|
||||||
|
body_html: string;
|
||||||
|
body_text: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_system: boolean;
|
||||||
|
variables_schema: Record<string, string> | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
const templates = $derived(data.templates as EmailTemplate[]);
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
let selectedCategory = $state<string>('all');
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let editingTemplate = $state<EmailTemplate | null>(null);
|
||||||
|
let showPreview = $state(false);
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
let editSubject = $state('');
|
||||||
|
let editBodyHtml = $state('');
|
||||||
|
let editBodyText = $state('');
|
||||||
|
let editIsActive = $state(true);
|
||||||
|
|
||||||
|
// Track which field is active for variable insertion
|
||||||
|
let activeField = $state<'subject' | 'body_html' | 'body_text'>('subject');
|
||||||
|
|
||||||
|
// Element refs
|
||||||
|
let subjectInput: HTMLInputElement;
|
||||||
|
let bodyHtmlTextarea: HTMLTextAreaElement;
|
||||||
|
let bodyTextTextarea: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// Sample data for preview
|
||||||
|
const sampleData: Record<string, string> = {
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
member_id: 'MUSA-0001',
|
||||||
|
portal_url: 'https://portal.monacousa.org',
|
||||||
|
amount: '$150.00',
|
||||||
|
due_date: 'February 15, 2026',
|
||||||
|
event_title: 'Monaco National Day Celebration',
|
||||||
|
event_date: 'November 19, 2026',
|
||||||
|
event_time: '6:00 PM',
|
||||||
|
event_location: 'The Monaco Club, New York',
|
||||||
|
guest_count: '2',
|
||||||
|
reset_link: 'https://portal.monacousa.org/reset-password?token=abc123',
|
||||||
|
verification_link: 'https://portal.monacousa.org/verify?token=abc123'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique categories
|
||||||
|
const categories = $derived.by(() => {
|
||||||
|
const cats = new Set(templates.map(t => t.category));
|
||||||
|
return ['all', ...Array.from(cats).sort()];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group templates by category
|
||||||
|
const templatesByCategory = $derived.by(() => {
|
||||||
|
return templates.reduce((acc, t) => {
|
||||||
|
const cat = t.category || 'other';
|
||||||
|
if (!acc[cat]) acc[cat] = [];
|
||||||
|
acc[cat].push(t);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, EmailTemplate[]>);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered templates
|
||||||
|
const filteredTemplates = $derived.by(() => {
|
||||||
|
let result = templates;
|
||||||
|
|
||||||
|
if (selectedCategory !== 'all') {
|
||||||
|
result = result.filter(t => t.category === selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.template_name.toLowerCase().includes(query) ||
|
||||||
|
t.template_key.toLowerCase().includes(query) ||
|
||||||
|
t.subject.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get variables for the current template
|
||||||
|
const templateVariables = $derived.by(() => {
|
||||||
|
if (!editingTemplate?.variables_schema) return [];
|
||||||
|
return Object.entries(editingTemplate.variables_schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format category name
|
||||||
|
function formatCategory(cat: string): string {
|
||||||
|
return cat.charAt(0).toUpperCase() + cat.slice(1).replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit modal
|
||||||
|
function openEditor(template: EmailTemplate) {
|
||||||
|
editingTemplate = template;
|
||||||
|
editSubject = template.subject;
|
||||||
|
editBodyHtml = template.body_html;
|
||||||
|
editBodyText = template.body_text;
|
||||||
|
editIsActive = template.is_active;
|
||||||
|
showPreview = false;
|
||||||
|
activeField = 'subject';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close edit modal
|
||||||
|
function closeEditor() {
|
||||||
|
editingTemplate = null;
|
||||||
|
showPreview = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert variable at cursor position
|
||||||
|
function insertVariable(varName: string) {
|
||||||
|
const tag = `{{${varName}}}`;
|
||||||
|
|
||||||
|
if (activeField === 'subject' && subjectInput) {
|
||||||
|
const start = subjectInput.selectionStart ?? editSubject.length;
|
||||||
|
const end = subjectInput.selectionEnd ?? editSubject.length;
|
||||||
|
editSubject = editSubject.slice(0, start) + tag + editSubject.slice(end);
|
||||||
|
// Restore focus and cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
subjectInput.focus();
|
||||||
|
subjectInput.setSelectionRange(start + tag.length, start + tag.length);
|
||||||
|
}, 0);
|
||||||
|
} else if (activeField === 'body_html' && bodyHtmlTextarea) {
|
||||||
|
const start = bodyHtmlTextarea.selectionStart ?? editBodyHtml.length;
|
||||||
|
const end = bodyHtmlTextarea.selectionEnd ?? editBodyHtml.length;
|
||||||
|
editBodyHtml = editBodyHtml.slice(0, start) + tag + editBodyHtml.slice(end);
|
||||||
|
setTimeout(() => {
|
||||||
|
bodyHtmlTextarea.focus();
|
||||||
|
bodyHtmlTextarea.setSelectionRange(start + tag.length, start + tag.length);
|
||||||
|
}, 0);
|
||||||
|
} else if (activeField === 'body_text' && bodyTextTextarea) {
|
||||||
|
const start = bodyTextTextarea.selectionStart ?? editBodyText.length;
|
||||||
|
const end = bodyTextTextarea.selectionEnd ?? editBodyText.length;
|
||||||
|
editBodyText = editBodyText.slice(0, start) + tag + editBodyText.slice(end);
|
||||||
|
setTimeout(() => {
|
||||||
|
bodyTextTextarea.focus();
|
||||||
|
bodyTextTextarea.setSelectionRange(start + tag.length, start + tag.length);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render preview with sample data (content only)
|
||||||
|
function renderPreview(html: string): string {
|
||||||
|
let result = html;
|
||||||
|
for (const [key, value] of Object.entries(sampleData)) {
|
||||||
|
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
||||||
|
}
|
||||||
|
// Also replace any remaining variables with placeholder
|
||||||
|
result = result.replace(/\{\{(\w+)\}\}/g, '<span style="color: #d97706; background: #fef3c7; padding: 2px 4px; border-radius: 4px;">[[$1]]</span>');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate full email HTML preview
|
||||||
|
// If template already has full HTML wrapper (legacy templates), render directly
|
||||||
|
// Otherwise wrap with Monaco template
|
||||||
|
function generateFullEmailPreview(subject: string, bodyHtml: string): string {
|
||||||
|
const renderedBody = renderPreview(bodyHtml);
|
||||||
|
|
||||||
|
// Check if template already has full HTML wrapper (legacy templates)
|
||||||
|
const hasFullWrapper = bodyHtml.includes('<!DOCTYPE') || bodyHtml.includes('<html');
|
||||||
|
|
||||||
|
if (hasFullWrapper) {
|
||||||
|
// Template has its own HTML structure - render as-is with variables replaced
|
||||||
|
// Also inject background image URL for S3
|
||||||
|
let html = renderedBody;
|
||||||
|
// Update background image URL if using old gradient-only style
|
||||||
|
html = html.replace(
|
||||||
|
/background:\s*linear-gradient\([^)]+\);\s*background-color:\s*#0f172a;/g,
|
||||||
|
"background-image: url('https://s3.monacousa.org/public/monaco_high_res.jpg'); background-size: cover; background-position: center; background-color: #0f172a;"
|
||||||
|
);
|
||||||
|
// Fix logo URL for preview
|
||||||
|
html = html.replace(/\{\{logo_url\}\}/g, '/MONACOUSA-Flags_376x376.png');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content-only template - wrap with Monaco template
|
||||||
|
const renderedSubject = renderPreview(subject);
|
||||||
|
const logoUrl = '/MONACOUSA-Flags_376x376.png';
|
||||||
|
const bgImageUrl = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
|
||||||
|
const emailTitle = renderedSubject.replace(/<[^>]*>/g, '').trim() || 'Email Preview';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-image: url('${bgImageUrl}'); background-size: cover; background-position: center; background-color: #0f172a;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.8) 0%, rgba(15, 23, 42, 0.6) 50%, rgba(127, 29, 29, 0.7) 100%);">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 30px;">
|
||||||
|
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||||
|
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||||
|
</div>
|
||||||
|
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px; text-align: center;">${emailTitle}</h2>
|
||||||
|
<div style="text-align: left;">${renderedBody}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top: 24px;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get preview HTML as data URL for iframe
|
||||||
|
const previewDataUrl = $derived.by(() => {
|
||||||
|
if (!showPreview || !editingTemplate) return '';
|
||||||
|
const html = generateFullEmailPreview(editSubject, editBodyHtml);
|
||||||
|
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get category color
|
||||||
|
function getCategoryColor(cat: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
auth: 'bg-blue-100 text-blue-700',
|
||||||
|
payment: 'bg-green-100 text-green-700',
|
||||||
|
events: 'bg-purple-100 text-purple-700',
|
||||||
|
membership: 'bg-amber-100 text-amber-700',
|
||||||
|
other: 'bg-slate-100 text-slate-700'
|
||||||
|
};
|
||||||
|
return colors[cat] || colors.other;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Email Templates | Monaco USA Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="/admin/settings"
|
||||||
|
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-monaco-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
Back to Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Email Templates</h1>
|
||||||
|
<p class="text-slate-500">Edit the text content of email notifications sent by the system</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg bg-green-50 border border-green-200 p-4 text-green-700">
|
||||||
|
<CheckCircle class="h-5 w-5 shrink-0" />
|
||||||
|
<span>{form.message || 'Template updated successfully'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-4 text-red-700">
|
||||||
|
<XCircle class="h-5 w-5 shrink-0" />
|
||||||
|
<span>{form.error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<!-- Category Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label for="category" class="text-sm font-medium text-slate-700">Category:</Label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
bind:value={selectedCategory}
|
||||||
|
class="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
|
||||||
|
>
|
||||||
|
{#each categories as cat}
|
||||||
|
<option value={cat}>{cat === 'all' ? 'All Categories' : formatCategory(cat)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative flex-1 sm:max-w-xs">
|
||||||
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search templates..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="h-9 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template Grid -->
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each filteredTemplates as template}
|
||||||
|
<div class="glass-card p-5 hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium {getCategoryColor(template.category)}">
|
||||||
|
{formatCategory(template.category)}
|
||||||
|
</span>
|
||||||
|
{#if !template.is_active}
|
||||||
|
<span class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-500">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold text-slate-900 truncate">{template.template_name}</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500 truncate">{template.subject}</p>
|
||||||
|
</div>
|
||||||
|
<Mail class="h-5 w-5 shrink-0 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<span class="text-xs text-slate-400">{template.template_key}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => openEditor(template)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="col-span-full glass-card p-12 text-center">
|
||||||
|
<FileText class="mx-auto h-12 w-12 text-slate-300" />
|
||||||
|
<p class="mt-4 text-slate-500">No templates found</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
{#if editingTemplate}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 backdrop-blur-sm"
|
||||||
|
onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="my-8 w-full max-w-4xl rounded-2xl bg-white shadow-2xl">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Edit: {editingTemplate.template_name}</h2>
|
||||||
|
<p class="text-sm text-slate-500">{editingTemplate.template_key}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={closeEditor}
|
||||||
|
class="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSubmitting = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
isSubmitting = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await invalidateAll();
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="template_key" value={editingTemplate.template_key} />
|
||||||
|
|
||||||
|
<!-- Subject Line -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="subject">Subject Line</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
type="text"
|
||||||
|
bind:value={editSubject}
|
||||||
|
bind:this={subjectInput}
|
||||||
|
onfocus={() => activeField = 'subject'}
|
||||||
|
class="h-11 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variables Panel -->
|
||||||
|
{#if templateVariables.length > 0}
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<Variable class="h-4 w-4 text-slate-600" />
|
||||||
|
<span class="text-sm font-medium text-slate-700">Available Variables</span>
|
||||||
|
<span class="text-xs text-slate-500">(click to insert at cursor)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each templateVariables as [varName, description]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => insertVariable(varName)}
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-mono text-slate-700 hover:bg-monaco-50 hover:border-monaco-300 hover:text-monaco-700 transition-colors"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
|
{`{{${varName}}}`}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Body HTML -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="body_html">Email Body (HTML)</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => showPreview = !showPreview}
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-monaco-600 hover:text-monaco-700"
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
{showPreview ? 'Edit' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showPreview}
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-900 overflow-hidden" style="min-height: 500px;">
|
||||||
|
<div class="bg-slate-800 px-4 py-2 border-b border-slate-700">
|
||||||
|
<p class="text-xs text-slate-400">Subject: <span class="text-white font-medium">{renderPreview(editSubject)}</span></p>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={previewDataUrl}
|
||||||
|
title="Email Preview"
|
||||||
|
class="w-full border-0"
|
||||||
|
style="height: 600px;"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<textarea
|
||||||
|
id="body_html"
|
||||||
|
name="body_html"
|
||||||
|
bind:value={editBodyHtml}
|
||||||
|
bind:this={bodyHtmlTextarea}
|
||||||
|
onfocus={() => activeField = 'body_html'}
|
||||||
|
rows="12"
|
||||||
|
class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm text-slate-900 focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20 resize-y"
|
||||||
|
></textarea>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body Text -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="body_text">Plain Text Version (fallback)</Label>
|
||||||
|
<textarea
|
||||||
|
id="body_text"
|
||||||
|
name="body_text"
|
||||||
|
bind:value={editBodyText}
|
||||||
|
bind:this={bodyTextTextarea}
|
||||||
|
onfocus={() => activeField = 'body_text'}
|
||||||
|
rows="6"
|
||||||
|
class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm text-slate-900 focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20 resize-y"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Toggle -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={editIsActive}
|
||||||
|
class="peer sr-only"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="is_active" value={editIsActive ? 'true' : 'false'} />
|
||||||
|
<div class="peer h-6 w-11 rounded-full bg-slate-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all peer-checked:bg-monaco-600 peer-checked:after:translate-x-5"></div>
|
||||||
|
</label>
|
||||||
|
<span class="text-sm text-slate-700">Template Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
|
||||||
|
<Button type="button" variant="outline" onclick={closeEditor} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="monaco" disabled={isSubmitting}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
{:else}
|
||||||
|
<Save class="mr-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { supabaseAdmin } from '$lib/server/supabase';
|
||||||
|
import { sendEmail, sendTemplatedEmail, getSmtpConfig, wrapInMonacoTemplate } from '$lib/server/email';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return {
|
||||||
|
templates: [],
|
||||||
|
recentLogs: [],
|
||||||
|
smtpConfigured: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SMTP is configured
|
||||||
|
const smtpConfig = await getSmtpConfig();
|
||||||
|
const smtpConfigured = !!smtpConfig;
|
||||||
|
|
||||||
|
// Fetch all email templates
|
||||||
|
const { data: templates } = await supabaseAdmin
|
||||||
|
.from('email_templates')
|
||||||
|
.select('*')
|
||||||
|
.order('category', { ascending: true })
|
||||||
|
.order('name', { ascending: true });
|
||||||
|
|
||||||
|
// Fetch recent email logs
|
||||||
|
const { data: recentLogs } = await supabaseAdmin
|
||||||
|
.from('email_logs')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
sender:members!email_logs_sent_by_fkey(first_name, last_name)
|
||||||
|
`)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
// Group templates by category
|
||||||
|
const templatesByCategory: Record<string, typeof templates> = {};
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const category = template.category || 'other';
|
||||||
|
if (!templatesByCategory[category]) {
|
||||||
|
templatesByCategory[category] = [];
|
||||||
|
}
|
||||||
|
templatesByCategory[category].push(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates: templates || [],
|
||||||
|
templatesByCategory,
|
||||||
|
recentLogs: recentLogs || [],
|
||||||
|
smtpConfigured,
|
||||||
|
adminEmail: member.email
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
/**
|
||||||
|
* Send a test email using a template
|
||||||
|
*/
|
||||||
|
sendTestTemplate: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Only admins can send test emails' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const templateKey = formData.get('template_key') as string;
|
||||||
|
const recipientEmail = formData.get('recipient_email') as string;
|
||||||
|
|
||||||
|
if (!templateKey || !recipientEmail) {
|
||||||
|
return fail(400, { error: 'Template and recipient email are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(recipientEmail)) {
|
||||||
|
return fail(400, { error: 'Please enter a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build test variables based on template type
|
||||||
|
const testVariables = getTestVariables(templateKey, member, url.origin);
|
||||||
|
|
||||||
|
const result = await sendTemplatedEmail(templateKey, recipientEmail, testVariables, {
|
||||||
|
recipientId: member.id,
|
||||||
|
recipientName: `${member.first_name} ${member.last_name}`,
|
||||||
|
sentBy: member.id,
|
||||||
|
baseUrl: url.origin
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(500, { error: result.error || 'Failed to send test email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: `Test email sent successfully to ${recipientEmail}!` };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a custom test email
|
||||||
|
*/
|
||||||
|
sendCustomEmail: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Only admins can send test emails' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const recipientEmail = formData.get('recipient_email') as string;
|
||||||
|
const subject = formData.get('subject') as string;
|
||||||
|
const messageContent = formData.get('message') as string;
|
||||||
|
|
||||||
|
if (!recipientEmail || !subject || !messageContent) {
|
||||||
|
return fail(400, { error: 'Recipient, subject, and message are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(recipientEmail)) {
|
||||||
|
return fail(400, { error: 'Please enter a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the message in the Monaco template
|
||||||
|
const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
|
||||||
|
const html = wrapInMonacoTemplate({
|
||||||
|
title: subject,
|
||||||
|
content: `<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">${messageContent.replace(/\n/g, '<br>')}</p>`,
|
||||||
|
logoUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendEmail({
|
||||||
|
to: recipientEmail,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
emailType: 'test_custom',
|
||||||
|
sentBy: member.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(500, { error: result.error || 'Failed to send email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: `Custom email sent successfully to ${recipientEmail}!` };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test all notification types to a single recipient
|
||||||
|
*/
|
||||||
|
sendAllTests: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Only admins can send test emails' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const recipientEmail = formData.get('recipient_email') as string;
|
||||||
|
|
||||||
|
if (!recipientEmail) {
|
||||||
|
return fail(400, { error: 'Recipient email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active templates
|
||||||
|
const { data: templates } = await supabaseAdmin
|
||||||
|
.from('email_templates')
|
||||||
|
.select('template_key')
|
||||||
|
.eq('is_active', true);
|
||||||
|
|
||||||
|
if (!templates || templates.length === 0) {
|
||||||
|
return fail(400, { error: 'No active email templates found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
const testVariables = getTestVariables(template.template_key, member, url.origin);
|
||||||
|
|
||||||
|
const result = await sendTemplatedEmail(template.template_key, recipientEmail, testVariables, {
|
||||||
|
recipientId: member.id,
|
||||||
|
recipientName: `${member.first_name} ${member.last_name}`,
|
||||||
|
sentBy: member.id,
|
||||||
|
baseUrl: url.origin
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
errors.push(`${template.template_key}: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between emails to avoid rate limiting
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
return { success: `All ${successCount} test emails sent successfully!` };
|
||||||
|
} else if (successCount === 0) {
|
||||||
|
return fail(500, { error: `All emails failed to send. Errors: ${errors.join('; ')}` });
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: `Sent ${successCount} emails, ${failCount} failed.`,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview a template (returns HTML)
|
||||||
|
*/
|
||||||
|
previewTemplate: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const templateKey = formData.get('template_key') as string;
|
||||||
|
|
||||||
|
if (!templateKey) {
|
||||||
|
return fail(400, { error: 'Template key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the template
|
||||||
|
const { data: template } = await supabaseAdmin
|
||||||
|
.from('email_templates')
|
||||||
|
.select('*')
|
||||||
|
.eq('template_key', templateKey)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return fail(404, { error: 'Template not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get test variables
|
||||||
|
const testVariables = getTestVariables(templateKey, member, url.origin);
|
||||||
|
const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
|
||||||
|
|
||||||
|
// Replace variables in the template
|
||||||
|
let html = template.body_html;
|
||||||
|
let subject = template.subject;
|
||||||
|
|
||||||
|
const allVariables: Record<string, string> = {
|
||||||
|
logo_url: logoUrl,
|
||||||
|
site_url: url.origin,
|
||||||
|
...testVariables
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(allVariables)) {
|
||||||
|
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||||
|
html = html.replace(regex, value);
|
||||||
|
subject = subject.replace(regex, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview: {
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
templateName: template.name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get test variables for different template types
|
||||||
|
*/
|
||||||
|
function getTestVariables(templateKey: string, member: any, baseUrl: string): Record<string, string> {
|
||||||
|
const commonVars = {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
last_name: member.last_name || 'User',
|
||||||
|
member_name: `${member.first_name || 'Test'} ${member.last_name || 'User'}`,
|
||||||
|
member_id: member.member_id || 'TEST-001',
|
||||||
|
email: member.email || 'test@example.com',
|
||||||
|
site_url: baseUrl,
|
||||||
|
portal_url: baseUrl,
|
||||||
|
logo_url: `${baseUrl}/MONACOUSA-Flags_376x376.png`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Template-specific variables
|
||||||
|
switch (templateKey) {
|
||||||
|
case 'welcome':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
login_url: `${baseUrl}/login`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'payment_received':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
amount: '100.00',
|
||||||
|
payment_date: new Date().toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
reference: 'TEST-REF-123',
|
||||||
|
due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'dues_reminder_30':
|
||||||
|
case 'dues_reminder_7':
|
||||||
|
case 'dues_reminder_1':
|
||||||
|
const daysMap: Record<string, string> = {
|
||||||
|
dues_reminder_30: '30',
|
||||||
|
dues_reminder_7: '7',
|
||||||
|
dues_reminder_1: '1'
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
days_until_due: daysMap[templateKey] || '30',
|
||||||
|
due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
amount_due: '100.00',
|
||||||
|
payment_url: `${baseUrl}/payments`,
|
||||||
|
bank_name: 'Monaco Bank',
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
bic: 'MONACOXX',
|
||||||
|
payment_reference: `DUES-${member.member_id || 'TEST001'}`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'dues_overdue':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
days_overdue: '15',
|
||||||
|
due_date: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
amount_due: '100.00',
|
||||||
|
payment_url: `${baseUrl}/payments`,
|
||||||
|
bank_name: 'Monaco Bank',
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
bic: 'MONACOXX',
|
||||||
|
payment_reference: `DUES-${member.member_id || 'TEST001'}`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'dues_grace_warning':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
grace_days_remaining: '7',
|
||||||
|
grace_end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
amount_due: '100.00',
|
||||||
|
payment_url: `${baseUrl}/payments`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'dues_inactive_notice':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
inactive_date: new Date().toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
reactivation_url: `${baseUrl}/payments`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'event_invitation':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
event_title: 'Monaco USA Annual Gala',
|
||||||
|
event_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
event_time: '7:00 PM',
|
||||||
|
event_location: 'Hotel de Paris, Monaco',
|
||||||
|
event_description: 'Join us for our annual celebration bringing together Americans living in Monaco.',
|
||||||
|
event_url: `${baseUrl}/events/test-event`,
|
||||||
|
rsvp_url: `${baseUrl}/events/test-event`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'event_reminder':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
event_title: 'Monaco USA Monthly Meetup',
|
||||||
|
event_date: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
event_time: '6:00 PM',
|
||||||
|
event_location: 'Stars n Bars, Monaco',
|
||||||
|
event_url: `${baseUrl}/events/test-event`
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'waitlist_promotion':
|
||||||
|
return {
|
||||||
|
...commonVars,
|
||||||
|
event_title: 'Exclusive Wine Tasting Event',
|
||||||
|
event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
event_time: '8:00 PM',
|
||||||
|
event_location: 'Cave Princesse, Monaco',
|
||||||
|
event_url: `${baseUrl}/events/test-event`,
|
||||||
|
confirm_url: `${baseUrl}/events/test-event`
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return commonVars;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,561 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
Inbox,
|
||||||
|
Settings
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const templates = $derived(data.templates);
|
||||||
|
const templatesByCategory = $derived(data.templatesByCategory);
|
||||||
|
const recentLogs = $derived(data.recentLogs);
|
||||||
|
const smtpConfigured = $derived(data.smtpConfigured);
|
||||||
|
const adminEmail = $derived(data.adminEmail);
|
||||||
|
|
||||||
|
let selectedTemplate = $state<string>('');
|
||||||
|
let recipientEmail = $state(adminEmail || '');
|
||||||
|
let customSubject = $state('Test Email from Monaco USA');
|
||||||
|
let customMessage = $state('This is a test email sent from the Monaco USA admin panel.');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let activeTab = $state<'templates' | 'custom' | 'logs'>('templates');
|
||||||
|
|
||||||
|
// Preview modal state
|
||||||
|
let showPreview = $state(false);
|
||||||
|
let previewHtml = $state('');
|
||||||
|
let previewSubject = $state('');
|
||||||
|
let isLoadingPreview = $state(false);
|
||||||
|
|
||||||
|
// Get category display name
|
||||||
|
function getCategoryName(category: string): string {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
dues: 'Dues & Payments',
|
||||||
|
events: 'Events',
|
||||||
|
auth: 'Authentication',
|
||||||
|
welcome: 'Welcome & Onboarding',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
other: 'Other'
|
||||||
|
};
|
||||||
|
return names[category] || category.charAt(0).toUpperCase() + category.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge styling
|
||||||
|
function getStatusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'sent':
|
||||||
|
return { color: 'bg-green-100 text-green-700', icon: CheckCircle2 };
|
||||||
|
case 'failed':
|
||||||
|
return { color: 'bg-red-100 text-red-700', icon: XCircle };
|
||||||
|
case 'pending':
|
||||||
|
return { color: 'bg-yellow-100 text-yellow-700', icon: Clock };
|
||||||
|
default:
|
||||||
|
return { color: 'bg-slate-100 text-slate-700', icon: Mail };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Email Testing | Monaco USA Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Email Testing</h1>
|
||||||
|
<p class="text-slate-500">Test email notifications across the platform</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/admin/settings"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
Email Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP Status Banner -->
|
||||||
|
{#if !smtpConfigured}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-amber-800">SMTP Not Configured</h3>
|
||||||
|
<p class="mt-1 text-sm text-amber-700">
|
||||||
|
Email sending is not configured. Please configure SMTP settings in the
|
||||||
|
<a href="/admin/settings" class="underline hover:text-amber-900">Admin Settings</a>
|
||||||
|
before testing emails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-green-800">
|
||||||
|
<CheckCircle2 class="h-5 w-5" />
|
||||||
|
<span class="font-medium">{form.success}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-red-800">
|
||||||
|
<XCircle class="h-5 w-5" />
|
||||||
|
<span class="font-medium">{form.error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex gap-2 border-b border-slate-200">
|
||||||
|
<button
|
||||||
|
onclick={() => (activeTab = 'templates')}
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'templates'
|
||||||
|
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
Email Templates
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (activeTab = 'custom')}
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'custom'
|
||||||
|
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<Mail class="h-4 w-4" />
|
||||||
|
Custom Email
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (activeTab = 'logs')}
|
||||||
|
class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'logs'
|
||||||
|
? 'border-b-2 border-monaco-600 text-monaco-600'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<Inbox class="h-4 w-4" />
|
||||||
|
Recent Logs ({recentLogs.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Templates Tab -->
|
||||||
|
{#if activeTab === 'templates'}
|
||||||
|
<div class="grid gap-6 lg:grid-cols-3">
|
||||||
|
<!-- Template List -->
|
||||||
|
<div class="lg:col-span-2 space-y-4">
|
||||||
|
{#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
<div class="border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<h3 class="font-semibold text-slate-900">{getCategoryName(category)}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-slate-100">
|
||||||
|
{#each categoryTemplates as template}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-4 hover:bg-slate-50 cursor-pointer {selectedTemplate === template.template_key ? 'bg-monaco-50 border-l-4 border-monaco-600' : ''}"
|
||||||
|
onclick={() => (selectedTemplate = template.template_key)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (selectedTemplate = template.template_key)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-slate-900">{template.name}</p>
|
||||||
|
<p class="text-sm text-slate-500 truncate">{template.subject}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if template.is_active}
|
||||||
|
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if !templates || templates.length === 0}
|
||||||
|
<div class="glass-card p-12 text-center">
|
||||||
|
<FileText class="mx-auto h-12 w-12 text-slate-300" />
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-slate-900">No Email Templates</h3>
|
||||||
|
<p class="mt-2 text-slate-500">
|
||||||
|
No email templates have been configured yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Test Panel -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="glass-card p-6 sticky top-6">
|
||||||
|
<h3 class="font-semibold text-slate-900 mb-4">Send Test Email</h3>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/sendTestTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label for="recipient_email">Recipient Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="recipient_email"
|
||||||
|
name="recipient_email"
|
||||||
|
bind:value={recipientEmail}
|
||||||
|
placeholder="test@example.com"
|
||||||
|
required
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="template_key">Selected Template</Label>
|
||||||
|
<select
|
||||||
|
id="template_key"
|
||||||
|
name="template_key"
|
||||||
|
bind:value={selectedTemplate}
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a template...</option>
|
||||||
|
{#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
|
||||||
|
<optgroup label={getCategoryName(category)}>
|
||||||
|
{#each categoryTemplates as template}
|
||||||
|
<option value={template.template_key}>{template.name}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="monaco"
|
||||||
|
disabled={isLoading || !smtpConfigured || !selectedTemplate}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
{:else}
|
||||||
|
<Send class="mr-2 h-4 w-4" />
|
||||||
|
Send Test
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Preview Button -->
|
||||||
|
{#if selectedTemplate}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/previewTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoadingPreview = true;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
isLoadingPreview = false;
|
||||||
|
if (result.type === 'success' && result.data?.preview) {
|
||||||
|
previewHtml = result.data.preview.html;
|
||||||
|
previewSubject = result.data.preview.subject;
|
||||||
|
showPreview = true;
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="template_key" value={selectedTemplate} />
|
||||||
|
<Button type="submit" variant="outline" disabled={isLoadingPreview} class="w-full">
|
||||||
|
{#if isLoadingPreview}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
{:else}
|
||||||
|
<Eye class="mr-2 h-4 w-4" />
|
||||||
|
Preview Template
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Send All Tests -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-slate-200">
|
||||||
|
<h4 class="text-sm font-medium text-slate-700 mb-3">Bulk Testing</h4>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/sendAllTests"
|
||||||
|
use:enhance={() => {
|
||||||
|
if (!confirm(`This will send ALL active email templates to ${recipientEmail}. Continue?`)) {
|
||||||
|
return async () => {};
|
||||||
|
}
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="recipient_email" value={recipientEmail} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading || !smtpConfigured || !recipientEmail}
|
||||||
|
class="w-full border-amber-300 text-amber-700 hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Send All Templates
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
Sends all active templates to test the complete notification system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Custom Email Tab -->
|
||||||
|
{#if activeTab === 'custom'}
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<div class="glass-card p-6">
|
||||||
|
<h3 class="font-semibold text-slate-900 mb-4">Send Custom Test Email</h3>
|
||||||
|
<p class="text-sm text-slate-500 mb-6">
|
||||||
|
Send a custom email using the Monaco USA branding template.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/sendCustomEmail"
|
||||||
|
use:enhance={() => {
|
||||||
|
isLoading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
isLoading = false;
|
||||||
|
await update();
|
||||||
|
await invalidateAll();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label for="custom_recipient">Recipient Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="custom_recipient"
|
||||||
|
name="recipient_email"
|
||||||
|
bind:value={recipientEmail}
|
||||||
|
placeholder="test@example.com"
|
||||||
|
required
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="custom_subject">Subject</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="custom_subject"
|
||||||
|
name="subject"
|
||||||
|
bind:value={customSubject}
|
||||||
|
placeholder="Email subject"
|
||||||
|
required
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="custom_message">Message</Label>
|
||||||
|
<textarea
|
||||||
|
id="custom_message"
|
||||||
|
name="message"
|
||||||
|
bind:value={customMessage}
|
||||||
|
rows="6"
|
||||||
|
placeholder="Your message here..."
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
The message will be wrapped in the Monaco USA email template.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" variant="monaco" disabled={isLoading || !smtpConfigured}>
|
||||||
|
{#if isLoading}
|
||||||
|
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
{:else}
|
||||||
|
<Send class="mr-2 h-4 w-4" />
|
||||||
|
Send Custom Email
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Logs Tab -->
|
||||||
|
{#if activeTab === 'logs'}
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
<div class="border-b border-slate-200 bg-slate-50 px-6 py-4 flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold text-slate-900">Recent Email Logs</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => invalidateAll()}
|
||||||
|
class="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recentLogs.length === 0}
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<Inbox class="mx-auto h-12 w-12 text-slate-300" />
|
||||||
|
<h3 class="mt-4 text-lg font-medium text-slate-900">No Email Logs</h3>
|
||||||
|
<p class="mt-2 text-slate-500">No emails have been sent yet.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Recipient</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Subject</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Sent</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{#each recentLogs as log}
|
||||||
|
{@const statusBadge = getStatusBadge(log.status)}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {statusBadge.color}">
|
||||||
|
<svelte:component this={statusBadge.icon} class="h-3 w-3" />
|
||||||
|
{log.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-900">{log.recipient_email}</p>
|
||||||
|
{#if log.recipient_name}
|
||||||
|
<p class="text-xs text-slate-500">{log.recipient_name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<p class="text-sm text-slate-900 max-w-xs truncate" title={log.subject}>
|
||||||
|
{log.subject}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
|
||||||
|
{log.email_type || 'manual'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-slate-500">
|
||||||
|
{log.sent_at ? formatDate(log.sent_at) : formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-slate-500">
|
||||||
|
{#if log.sender}
|
||||||
|
{log.sender.first_name} {log.sender.last_name}
|
||||||
|
{:else}
|
||||||
|
System
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if log.status === 'failed' && log.error_message}
|
||||||
|
<tr class="bg-red-50">
|
||||||
|
<td colspan="6" class="px-6 py-2">
|
||||||
|
<p class="text-xs text-red-600">
|
||||||
|
<strong>Error:</strong> {log.error_message}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Modal -->
|
||||||
|
{#if showPreview}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-4xl max-h-[90vh] flex flex-col rounded-2xl bg-white shadow-xl">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-slate-900">Email Preview</h3>
|
||||||
|
<p class="text-sm text-slate-500">{previewSubject}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (showPreview = false)}
|
||||||
|
class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto p-4 bg-slate-100">
|
||||||
|
<div class="mx-auto max-w-lg">
|
||||||
|
{@html previewHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-slate-200 px-6 py-4">
|
||||||
|
<Button variant="outline" onclick={() => (showPreview = false)}>
|
||||||
|
Close Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,430 @@
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { supabaseAdmin } from '$lib/server/supabase';
|
||||||
|
import { sendEmail } from '$lib/server/email';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
|
const searchQuery = url.searchParams.get('search') || '';
|
||||||
|
const roleFilter = url.searchParams.get('role') || 'all';
|
||||||
|
const statusFilter = url.searchParams.get('status') || 'all';
|
||||||
|
|
||||||
|
// Load all members with dues info using admin client (bypasses RLS for admin page)
|
||||||
|
const { data: members } = await supabaseAdmin
|
||||||
|
.from('members_with_dues')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
// Filter members
|
||||||
|
let filteredMembers = members || [];
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
const lowerSearch = searchQuery.toLowerCase();
|
||||||
|
filteredMembers = filteredMembers.filter(
|
||||||
|
(m: any) =>
|
||||||
|
m.first_name?.toLowerCase().includes(lowerSearch) ||
|
||||||
|
m.last_name?.toLowerCase().includes(lowerSearch) ||
|
||||||
|
m.email?.toLowerCase().includes(lowerSearch) ||
|
||||||
|
m.member_id?.toLowerCase().includes(lowerSearch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleFilter !== 'all') {
|
||||||
|
filteredMembers = filteredMembers.filter((m: any) => m.role === roleFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load membership statuses for dropdown
|
||||||
|
const { data: statuses } = await supabaseAdmin
|
||||||
|
.from('membership_statuses')
|
||||||
|
.select('*')
|
||||||
|
.order('sort_order', { ascending: true });
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
total: members?.length || 0,
|
||||||
|
admins: members?.filter((m: any) => m.role === 'admin').length || 0,
|
||||||
|
board: members?.filter((m: any) => m.role === 'board').length || 0,
|
||||||
|
members: members?.filter((m: any) => m.role === 'member').length || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
members: filteredMembers,
|
||||||
|
statuses: statuses || [],
|
||||||
|
stats,
|
||||||
|
filters: {
|
||||||
|
search: searchQuery,
|
||||||
|
role: roleFilter,
|
||||||
|
status: statusFilter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
updateRole: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const memberId = formData.get('member_id') as string;
|
||||||
|
const newRole = formData.get('role') as string;
|
||||||
|
|
||||||
|
if (!memberId || !newRole) {
|
||||||
|
return fail(400, { error: 'Member ID and role are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['member', 'board', 'admin'].includes(newRole)) {
|
||||||
|
return fail(400, { error: 'Invalid role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase
|
||||||
|
.from('members')
|
||||||
|
.update({ role: newRole, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', memberId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Update role error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Role updated successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const memberId = formData.get('member_id') as string;
|
||||||
|
const statusId = formData.get('status_id') as string;
|
||||||
|
|
||||||
|
if (!memberId || !statusId) {
|
||||||
|
return fail(400, { error: 'Member ID and status are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase
|
||||||
|
.from('members')
|
||||||
|
.update({
|
||||||
|
membership_status_id: statusId,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', memberId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Update status error:', error);
|
||||||
|
return fail(500, { error: 'Failed to update status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Status updated successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMember: async ({ request, locals }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Only admins can delete members' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const memberId = formData.get('member_id') as string;
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
return fail(400, { error: 'Member ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent admin from deleting themselves
|
||||||
|
if (memberId === member.id) {
|
||||||
|
return fail(400, { error: 'You cannot delete your own account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First handle related records that have foreign keys to members
|
||||||
|
|
||||||
|
// Reassign events created by this member to the current admin
|
||||||
|
await supabaseAdmin
|
||||||
|
.from('events')
|
||||||
|
.update({ created_by: member.id })
|
||||||
|
.eq('created_by', memberId);
|
||||||
|
|
||||||
|
// Reassign app_settings updated by this member to the current admin
|
||||||
|
await supabaseAdmin
|
||||||
|
.from('app_settings')
|
||||||
|
.update({ updated_by: member.id })
|
||||||
|
.eq('updated_by', memberId);
|
||||||
|
|
||||||
|
// Delete dues payments
|
||||||
|
await supabaseAdmin.from('dues_payments').delete().eq('member_id', memberId);
|
||||||
|
|
||||||
|
// Delete event RSVPs
|
||||||
|
await supabaseAdmin.from('event_rsvps').delete().eq('member_id', memberId);
|
||||||
|
|
||||||
|
// Delete email logs
|
||||||
|
await supabaseAdmin.from('email_logs').delete().eq('recipient_id', memberId);
|
||||||
|
await supabaseAdmin.from('email_logs').delete().eq('sent_by', memberId);
|
||||||
|
|
||||||
|
// Now delete from members table using admin client (bypasses RLS)
|
||||||
|
const { error } = await supabaseAdmin.from('members').delete().eq('id', memberId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Delete member error:', error);
|
||||||
|
console.error('Error details:', JSON.stringify(error, null, 2));
|
||||||
|
return fail(500, { error: `Failed to delete member: ${error.message}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete the auth user using admin client
|
||||||
|
const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(memberId);
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
console.error('Delete auth user error:', authError);
|
||||||
|
// Member is already deleted, just log this
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Member deleted successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
inviteMember: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Only admins can invite members' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
||||||
|
const firstName = (formData.get('first_name') as string)?.trim() || '';
|
||||||
|
const lastName = (formData.get('last_name') as string)?.trim() || '';
|
||||||
|
const role = (formData.get('role') as string) || 'member';
|
||||||
|
const duesPaidDate = formData.get('dues_paid_date') as string;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return fail(400, { error: 'Email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
if (!['member', 'board', 'admin'].includes(role)) {
|
||||||
|
return fail(400, { error: 'Invalid role' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return fail(400, { error: 'Please enter a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const { data: existingMember } = await locals.supabase
|
||||||
|
.from('members')
|
||||||
|
.select('id')
|
||||||
|
.eq('email', email)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existingMember) {
|
||||||
|
return fail(400, { error: 'A member with this email already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default status (pending)
|
||||||
|
const { data: defaultStatus } = await locals.supabase
|
||||||
|
.from('membership_statuses')
|
||||||
|
.select('id')
|
||||||
|
.eq('is_default', true)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Get default membership type
|
||||||
|
const { data: defaultType } = await locals.supabase
|
||||||
|
.from('membership_types')
|
||||||
|
.select('id, annual_dues')
|
||||||
|
.eq('is_default', true)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Create auth user with a temporary password using admin client (requires service_role)
|
||||||
|
// The user will reset their password when they first log in
|
||||||
|
const tempPassword = crypto.randomUUID();
|
||||||
|
|
||||||
|
const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: tempPassword,
|
||||||
|
email_confirm: true, // Auto-confirm the email since admin is inviting
|
||||||
|
user_metadata: {
|
||||||
|
first_name: firstName || 'New',
|
||||||
|
last_name: lastName || 'Member',
|
||||||
|
invited_by: member.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
console.error('Auth user creation error:', authError);
|
||||||
|
if (authError.message.includes('already registered')) {
|
||||||
|
return fail(400, { error: 'This email is already registered' });
|
||||||
|
}
|
||||||
|
return fail(500, { error: 'Failed to create user account. Please try again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authData.user) {
|
||||||
|
return fail(500, { error: 'Failed to create user account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active status if dues are paid
|
||||||
|
let statusId = defaultStatus?.id;
|
||||||
|
if (duesPaidDate) {
|
||||||
|
const { data: activeStatus } = await locals.supabase
|
||||||
|
.from('membership_statuses')
|
||||||
|
.select('id')
|
||||||
|
.eq('name', 'active')
|
||||||
|
.single();
|
||||||
|
if (activeStatus) {
|
||||||
|
statusId = activeStatus.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create member record
|
||||||
|
const { error: memberError } = await locals.supabase.from('members').insert({
|
||||||
|
id: authData.user.id,
|
||||||
|
first_name: firstName || 'New',
|
||||||
|
last_name: lastName || 'Member',
|
||||||
|
email: email,
|
||||||
|
phone: '',
|
||||||
|
date_of_birth: '1990-01-01', // Placeholder - member will update
|
||||||
|
address: 'TBD', // Placeholder
|
||||||
|
nationality: [],
|
||||||
|
role: role,
|
||||||
|
membership_status_id: statusId,
|
||||||
|
membership_type_id: defaultType?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memberError) {
|
||||||
|
console.error('Member creation error:', memberError);
|
||||||
|
// Clean up auth user using admin client
|
||||||
|
await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
|
||||||
|
return fail(500, { error: 'Failed to create member record. Please try again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dues payment record if dues paid date is provided
|
||||||
|
// The dues_paid_date is the date when dues are NEXT due (not when paid)
|
||||||
|
// So we calculate payment_date as 1 year before the due date
|
||||||
|
if (duesPaidDate) {
|
||||||
|
const dueDate = new Date(duesPaidDate);
|
||||||
|
const paymentDate = new Date(dueDate);
|
||||||
|
paymentDate.setFullYear(paymentDate.getFullYear() - 1);
|
||||||
|
|
||||||
|
const { error: duesError } = await locals.supabase.from('dues_payments').insert({
|
||||||
|
member_id: authData.user.id,
|
||||||
|
amount: defaultType?.annual_dues || 0,
|
||||||
|
payment_date: paymentDate.toISOString().split('T')[0],
|
||||||
|
due_date: duesPaidDate,
|
||||||
|
payment_method: 'other',
|
||||||
|
notes: 'Initial dues set by admin during member invitation',
|
||||||
|
recorded_by: member.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duesError) {
|
||||||
|
console.error('Dues payment creation error:', duesError);
|
||||||
|
// Non-critical - member still created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send welcome email with Monaco branding
|
||||||
|
const baseUrl = url.origin;
|
||||||
|
const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
|
||||||
|
const memberFirstName = firstName || 'New Member';
|
||||||
|
|
||||||
|
// Format dues paid date for display
|
||||||
|
const formattedDuesPaidDate = duesPaidDate
|
||||||
|
? new Date(duesPaidDate).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Dues section for the email
|
||||||
|
const duesSection = duesPaidDate
|
||||||
|
? `<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||||
|
<p style="margin: 0 0 8px 0; color: #166534; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">Membership Status</p>
|
||||||
|
<p style="margin: 0 0 8px 0; color: #334155;"><strong>Status:</strong> Active Member</p>
|
||||||
|
<p style="margin: 0; color: #334155;"><strong>Dues Paid Through:</strong> ${formattedDuesPaidDate}</p>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const welcomeEmailResult = await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `Welcome to Monaco USA, ${memberFirstName}!`,
|
||||||
|
html: `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #0f172a;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(30, 41, 59, 0.85) 50%, rgba(127, 29, 29, 0.8) 100%); background-color: #0f172a;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<!-- Logo Section -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 30px;">
|
||||||
|
<div style="background: rgba(255, 255, 255, 0.95); border-radius: 16px; padding: 12px; display: inline-block; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);">
|
||||||
|
<img src="${logoUrl}" alt="Monaco USA" width="80" height="80" style="display: block; border-radius: 8px;">
|
||||||
|
</div>
|
||||||
|
<h1 style="margin: 16px 0 4px 0; font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);">Monaco <span style="color: #fca5a5;">USA</span></h1>
|
||||||
|
<p style="margin: 0; font-size: 14px; color: rgba(255, 255, 255, 0.8);">Americans in Monaco</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Main Content Card -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="max-width: 480px; width: 100%; background: #ffffff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #CE1126; font-size: 24px;">Welcome to Monaco USA!</h2>
|
||||||
|
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">Dear ${memberFirstName},</p>
|
||||||
|
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">We are thrilled to welcome you to the Monaco USA community! Your membership account has been created and you are now part of our growing network of Americans in Monaco.</p>
|
||||||
|
${duesSection}
|
||||||
|
<div style="background: #f8fafc; border-radius: 12px; padding: 20px; margin: 0 0 20px 0;">
|
||||||
|
<p style="margin: 0 0 12px 0; color: #334155; font-weight: 600;">To get started:</p>
|
||||||
|
<ol style="margin: 0; padding: 0 0 0 20px; color: #334155; line-height: 1.8;">
|
||||||
|
<li>You will receive a separate email shortly to set up your password</li>
|
||||||
|
<li>Log in to your member portal at <a href="${baseUrl}" style="color: #CE1126; text-decoration: none;">${baseUrl}</a></li>
|
||||||
|
<li>Complete your profile with your details</li>
|
||||||
|
<li>Explore upcoming events and connect with fellow members</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0 0 20px 0; color: #334155; line-height: 1.6;">If you have any questions, please don't hesitate to reach out to our board members.</p>
|
||||||
|
<p style="margin: 0; color: #334155;">Best regards,<br><strong style="color: #CE1126;">The Monaco USA Team</strong></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top: 24px;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: rgba(255, 255, 255, 0.5);">© 2026 Monaco USA. All rights reserved.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
recipientId: authData.user.id,
|
||||||
|
recipientName: `${firstName} ${lastName}`.trim() || 'New Member',
|
||||||
|
emailType: 'welcome',
|
||||||
|
sentBy: member.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!welcomeEmailResult.success) {
|
||||||
|
console.error('Welcome email error:', welcomeEmailResult.error);
|
||||||
|
// Non-critical - member still created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send password reset email so user can set their own password
|
||||||
|
const { error: resetError } = await locals.supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${url.origin}/auth/reset-password`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resetError) {
|
||||||
|
console.error('Password reset email error:', resetError);
|
||||||
|
// Member created but email failed - not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: `Invitation sent to ${email}! They will receive a welcome email and instructions to set up their password.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,596 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
UserCircle,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Calendar,
|
||||||
|
Trash2,
|
||||||
|
ChevronDown,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
UserPlus,
|
||||||
|
Send
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import CountryFlag from '$lib/components/ui/CountryFlag.svelte';
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
// Use $derived to make these reactive when data updates after invalidateAll()
|
||||||
|
const members = $derived(data.members);
|
||||||
|
const statuses = $derived(data.statuses);
|
||||||
|
const stats = $derived(data.stats);
|
||||||
|
const filters = $derived(data.filters);
|
||||||
|
|
||||||
|
let searchQuery = $state(filters.search);
|
||||||
|
let roleFilter = $state(filters.role);
|
||||||
|
let statusFilter = $state(filters.status);
|
||||||
|
let memberToDelete = $state<any>(null);
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let showInviteModal = $state(false);
|
||||||
|
let inviteLoading = $state(false);
|
||||||
|
let inviteEmail = $state('');
|
||||||
|
let inviteFirstName = $state('');
|
||||||
|
let inviteLastName = $state('');
|
||||||
|
let inviteRole = $state('member');
|
||||||
|
|
||||||
|
// Default dues paid date to one year from today
|
||||||
|
function getDefaultDuesPaidDate(): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setFullYear(date.getFullYear() + 1);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
let inviteDuesPaidDate = $state('');
|
||||||
|
|
||||||
|
// Open invite modal with default date
|
||||||
|
function openInviteModal() {
|
||||||
|
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||||
|
showInviteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close invite modal and reset form on success
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.success && form.success.includes('Invitation sent')) {
|
||||||
|
showInviteModal = false;
|
||||||
|
inviteEmail = '';
|
||||||
|
inviteFirstName = '';
|
||||||
|
inviteLastName = '';
|
||||||
|
inviteRole = 'member';
|
||||||
|
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close delete modal on success
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.success && form.success.includes('deleted')) {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
memberToDelete = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
function handleSearch(value: string) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
updateFilters({ search: value });
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilters(newFilters: Record<string, string>) {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams);
|
||||||
|
for (const [key, value] of Object.entries(newFilters)) {
|
||||||
|
if (value && value !== 'all') {
|
||||||
|
params.set(key, value);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
goto(`?${params.toString()}`, { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(member: any) {
|
||||||
|
memberToDelete = member;
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get role info
|
||||||
|
function getRoleInfo(role: string) {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
return { icon: ShieldCheck, color: 'text-purple-600', bg: 'bg-purple-100', label: 'Admin' };
|
||||||
|
case 'board':
|
||||||
|
return { icon: Shield, color: 'text-blue-600', bg: 'bg-blue-100', label: 'Board' };
|
||||||
|
default:
|
||||||
|
return { icon: UserCircle, color: 'text-slate-600', bg: 'bg-slate-100', label: 'Member' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status info
|
||||||
|
function getStatusInfo(status: string | null) {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100' };
|
||||||
|
case 'pending':
|
||||||
|
return { icon: Clock, color: 'text-yellow-600', bg: 'bg-yellow-100' };
|
||||||
|
case 'inactive':
|
||||||
|
return { icon: XCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||||
|
case 'expired':
|
||||||
|
return { icon: AlertTriangle, color: 'text-red-600', bg: 'bg-red-100' };
|
||||||
|
default:
|
||||||
|
return { icon: UserCircle, color: 'text-slate-500', bg: 'bg-slate-100' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>User Management | Monaco USA Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">User Management</h1>
|
||||||
|
<p class="text-slate-500">Manage member accounts, roles, and statuses</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="monaco" onclick={openInviteModal}>
|
||||||
|
<UserPlus class="mr-2 h-4 w-4" />
|
||||||
|
Invite Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||||
|
{form.success}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-slate-100 p-2">
|
||||||
|
<Users class="h-5 w-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-900">{stats.total}</p>
|
||||||
|
<p class="text-xs text-slate-500">Total Users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-purple-100 p-2">
|
||||||
|
<ShieldCheck class="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-900">{stats.admins}</p>
|
||||||
|
<p class="text-xs text-slate-500">Admins</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-blue-100 p-2">
|
||||||
|
<Shield class="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-900">{stats.board}</p>
|
||||||
|
<p class="text-xs text-slate-500">Board Members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-slate-100 p-2">
|
||||||
|
<UserCircle class="h-5 w-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-bold text-slate-900">{stats.members}</p>
|
||||||
|
<p class="text-xs text-slate-500">Members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name, email, or member ID..."
|
||||||
|
value={searchQuery}
|
||||||
|
oninput={(e) => {
|
||||||
|
searchQuery = e.currentTarget.value;
|
||||||
|
handleSearch(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
class="h-10 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
bind:value={roleFilter}
|
||||||
|
onchange={() => updateFilters({ role: roleFilter })}
|
||||||
|
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
bind:value={statusFilter}
|
||||||
|
onchange={() => updateFilters({ status: statusFilter })}
|
||||||
|
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
{#each statuses as status}
|
||||||
|
<option value={status.name}>{status.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members Table -->
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
{#if members.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||||
|
<Users class="mb-4 h-16 w-16 text-slate-300" />
|
||||||
|
<h3 class="text-lg font-medium text-slate-900">No users found</h3>
|
||||||
|
<p class="mt-1 text-slate-500">Try adjusting your search or filters.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">User</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Contact</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Role</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">Joined</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{#each members as member}
|
||||||
|
{@const roleInfo = getRoleInfo(member.role)}
|
||||||
|
{@const statusInfo = getStatusInfo(member.status_name)}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if member.avatar_url}
|
||||||
|
<img
|
||||||
|
src={member.avatar_url}
|
||||||
|
alt=""
|
||||||
|
class="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-full bg-monaco-100 text-monaco-700"
|
||||||
|
>
|
||||||
|
{member.first_name?.[0]}{member.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="font-medium text-slate-900">
|
||||||
|
{member.first_name} {member.last_name}
|
||||||
|
</p>
|
||||||
|
{#if member.nationality && member.nationality.length > 0}
|
||||||
|
<div class="flex items-center gap-0.5">
|
||||||
|
{#each member.nationality as code}
|
||||||
|
<CountryFlag {code} size="xs" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500">{member.member_id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<Mail class="h-3.5 w-3.5" />
|
||||||
|
{member.email}
|
||||||
|
</div>
|
||||||
|
{#if member.phone}
|
||||||
|
<div class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||||
|
<Phone class="h-3.5 w-3.5" />
|
||||||
|
{member.phone}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateRole"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="member_id" value={member.id} />
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={member.role}
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm font-medium {roleInfo.color} cursor-pointer hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateStatus"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="member_id" value={member.id} />
|
||||||
|
<select
|
||||||
|
name="status_id"
|
||||||
|
value={member.membership_status_id || ''}
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm cursor-pointer hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
{#each statuses as status}
|
||||||
|
<option value={status.id}>{status.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-slate-500">
|
||||||
|
{formatDate(member.member_since)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onclick={() => confirmDelete(member)}
|
||||||
|
class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
|
||||||
|
title="Delete Member"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteConfirm && memberToDelete}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="glass-card w-full max-w-md p-6">
|
||||||
|
<div class="mb-4 flex items-center gap-3 text-red-600">
|
||||||
|
<AlertTriangle class="h-6 w-6" />
|
||||||
|
<h3 class="text-lg font-semibold">Delete Member</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-4 text-slate-600">
|
||||||
|
Are you sure you want to delete <strong>{memberToDelete.first_name} {memberToDelete.last_name}</strong>
|
||||||
|
({memberToDelete.member_id})? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mb-6 text-sm text-slate-500">
|
||||||
|
This will permanently delete their account, payment history, and all associated data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
memberToDelete = null;
|
||||||
|
}}
|
||||||
|
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteMember"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
memberToDelete = null;
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="member_id" value={memberToDelete.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete Member
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Invite Member Modal -->
|
||||||
|
{#if showInviteModal}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="glass-card w-full max-w-md p-6">
|
||||||
|
<div class="mb-4 flex items-center gap-3 text-monaco-600">
|
||||||
|
<UserPlus class="h-6 w-6" />
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Invite New Member</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-6 text-sm text-slate-600">
|
||||||
|
Send an invitation email to a new member. They will receive instructions to set up their account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/inviteMember"
|
||||||
|
use:enhance={() => {
|
||||||
|
inviteLoading = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
inviteLoading = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_email">Email Address *</Label>
|
||||||
|
<Input
|
||||||
|
id="invite_email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="member@example.com"
|
||||||
|
bind:value={inviteEmail}
|
||||||
|
required
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_first_name">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="invite_first_name"
|
||||||
|
name="first_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John"
|
||||||
|
bind:value={inviteFirstName}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_last_name">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="invite_last_name"
|
||||||
|
name="last_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Doe"
|
||||||
|
bind:value={inviteLastName}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_role">Role</Label>
|
||||||
|
<select
|
||||||
|
id="invite_role"
|
||||||
|
name="role"
|
||||||
|
bind:value={inviteRole}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||||
|
>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="invite_dues_paid_date">Dues Paid Until</Label>
|
||||||
|
<input
|
||||||
|
id="invite_dues_paid_date"
|
||||||
|
name="dues_paid_date"
|
||||||
|
type="date"
|
||||||
|
value={inviteDuesPaidDate}
|
||||||
|
oninput={(e) => inviteDuesPaidDate = e.currentTarget.value}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
* Required field. Other fields are optional - the member can update their profile after joining.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
showInviteModal = false;
|
||||||
|
inviteEmail = '';
|
||||||
|
inviteFirstName = '';
|
||||||
|
inviteLastName = '';
|
||||||
|
inviteRole = 'member';
|
||||||
|
inviteDuesPaidDate = getDefaultDuesPaidDate();
|
||||||
|
}}
|
||||||
|
disabled={inviteLoading}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="monaco" disabled={inviteLoading || !inviteEmail} class="flex-1">
|
||||||
|
{#if inviteLoading}
|
||||||
|
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||||
|
Sending...
|
||||||
|
{:else}
|
||||||
|
<Send class="mr-2 h-4 w-4" />
|
||||||
|
Send Invitation
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,709 @@
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
|
||||||
|
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
|
||||||
|
import * as poste from '$lib/server/poste';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// Load all configurable data
|
||||||
|
const [
|
||||||
|
{ data: membershipStatuses },
|
||||||
|
{ data: membershipTypes },
|
||||||
|
{ data: eventTypes },
|
||||||
|
{ data: documentCategories },
|
||||||
|
{ data: appSettings },
|
||||||
|
{ data: emailTemplates }
|
||||||
|
] = await Promise.all([
|
||||||
|
locals.supabase.from('membership_statuses').select('*').order('sort_order', { ascending: true }),
|
||||||
|
locals.supabase.from('membership_types').select('*').order('sort_order', { ascending: true }),
|
||||||
|
locals.supabase.from('event_types').select('*').order('sort_order', { ascending: true }),
|
||||||
|
locals.supabase.from('document_categories').select('*').order('sort_order', { ascending: true }),
|
||||||
|
locals.supabase.from('app_settings').select('*'),
|
||||||
|
locals.supabase.from('email_templates').select('template_key, template_name, category').eq('is_active', true).order('category').order('template_name')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert settings to object by category
|
||||||
|
const settings: Record<string, Record<string, any>> = {};
|
||||||
|
for (const setting of appSettings || []) {
|
||||||
|
if (!settings[setting.category]) {
|
||||||
|
settings[setting.category] = {};
|
||||||
|
}
|
||||||
|
settings[setting.category][setting.setting_key] = setting.setting_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
membershipStatuses: membershipStatuses || [],
|
||||||
|
membershipTypes: membershipTypes || [],
|
||||||
|
eventTypes: eventTypes || [],
|
||||||
|
documentCategories: documentCategories || [],
|
||||||
|
settings,
|
||||||
|
emailTemplates: emailTemplates || []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
// Membership Status actions
|
||||||
|
createStatus: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const color = formData.get('color') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
|
if (!name || !displayName) {
|
||||||
|
return fail(400, { error: 'Name and display name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('membership_statuses').insert({
|
||||||
|
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
display_name: displayName,
|
||||||
|
color: color || '#6b7280',
|
||||||
|
description: description || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Create status error:', error);
|
||||||
|
return fail(500, { error: 'Failed to create status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Status created successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteStatus: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('membership_statuses').delete().eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Delete status error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Status deleted!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Membership Type actions
|
||||||
|
createType: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const annualDues = formData.get('annual_dues') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
|
if (!name || !displayName || !annualDues) {
|
||||||
|
return fail(400, { error: 'Name, display name, and annual dues are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('membership_types').insert({
|
||||||
|
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
display_name: displayName,
|
||||||
|
annual_dues: parseFloat(annualDues),
|
||||||
|
description: description || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Create type error:', error);
|
||||||
|
return fail(500, { error: 'Failed to create membership type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Membership type created successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteType: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('membership_types').delete().eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Delete type error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete membership type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Membership type deleted!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Event Type actions
|
||||||
|
createEventType: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const color = formData.get('color') as string;
|
||||||
|
|
||||||
|
if (!name || !displayName) {
|
||||||
|
return fail(400, { error: 'Name and display name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('event_types').insert({
|
||||||
|
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
display_name: displayName,
|
||||||
|
color: color || '#3b82f6'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Create event type error:', error);
|
||||||
|
return fail(500, { error: 'Failed to create event type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Event type created successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEventType: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('event_types').delete().eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Delete event type error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete event type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Event type deleted!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Document Category actions
|
||||||
|
createCategory: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
|
||||||
|
if (!name || !displayName) {
|
||||||
|
return fail(400, { error: 'Name and display name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('document_categories').insert({
|
||||||
|
name: name.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
display_name: displayName,
|
||||||
|
description: description || null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Create category error:', error);
|
||||||
|
return fail(500, { error: 'Failed to create document category' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Document category created successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCategory: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id') as string;
|
||||||
|
|
||||||
|
const { error } = await locals.supabase.from('document_categories').delete().eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Delete category error:', error);
|
||||||
|
return fail(500, { error: 'Failed to delete document category' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Document category deleted!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update app settings
|
||||||
|
updateSettings: async ({ request, locals }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
const formData = await request.formData();
|
||||||
|
const category = formData.get('category') as string;
|
||||||
|
|
||||||
|
// Get existing boolean settings for this category to handle unchecked checkboxes
|
||||||
|
const { data: existingSettings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_type')
|
||||||
|
.eq('category', category);
|
||||||
|
|
||||||
|
const existingBooleanKeys = new Set(
|
||||||
|
(existingSettings || [])
|
||||||
|
.filter(s => s.setting_type === 'boolean')
|
||||||
|
.map(s => s.setting_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all settings from form data
|
||||||
|
const settingsToUpdate: Array<{ key: string; value: any; type: string }> = [];
|
||||||
|
const processedKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key !== 'category' && key.startsWith('setting_')) {
|
||||||
|
const settingKey = key.replace('setting_', '');
|
||||||
|
processedKeys.add(settingKey);
|
||||||
|
// Handle checkbox values - they come as 'on' when checked
|
||||||
|
const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey);
|
||||||
|
settingsToUpdate.push({
|
||||||
|
key: settingKey,
|
||||||
|
value: isCheckbox ? (value === 'on' || value === 'true') : value,
|
||||||
|
type: isCheckbox ? 'boolean' : 'text'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unchecked checkboxes - they don't send any value
|
||||||
|
// For any existing boolean setting NOT in the form data, set to false
|
||||||
|
for (const booleanKey of existingBooleanKeys) {
|
||||||
|
if (!processedKeys.has(booleanKey)) {
|
||||||
|
settingsToUpdate.push({
|
||||||
|
key: booleanKey,
|
||||||
|
value: false,
|
||||||
|
type: 'boolean'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or insert each setting
|
||||||
|
for (const setting of settingsToUpdate) {
|
||||||
|
// Try to update first
|
||||||
|
const { data: existing } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('id')
|
||||||
|
.eq('category', category)
|
||||||
|
.eq('setting_key', setting.key)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing
|
||||||
|
await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.update({
|
||||||
|
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
updated_by: member?.id
|
||||||
|
})
|
||||||
|
.eq('category', category)
|
||||||
|
.eq('setting_key', setting.key);
|
||||||
|
} else {
|
||||||
|
// Insert new setting
|
||||||
|
await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.insert({
|
||||||
|
category,
|
||||||
|
setting_key: setting.key,
|
||||||
|
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
||||||
|
setting_type: setting.type,
|
||||||
|
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
updated_by: member?.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear caches if storage settings were updated
|
||||||
|
if (category === 'storage') {
|
||||||
|
clearS3ClientCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Settings updated successfully!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test SMTP connection
|
||||||
|
testSmtp: async ({ request, locals }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
const formData = await request.formData();
|
||||||
|
const testEmail = formData.get('test_email') as string;
|
||||||
|
|
||||||
|
// Use the member's email if no test email is provided
|
||||||
|
const recipientEmail = testEmail || member?.email;
|
||||||
|
|
||||||
|
if (!recipientEmail) {
|
||||||
|
return fail(400, { error: 'No email address provided for test' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SMTP connection and send a test email
|
||||||
|
const result = await testSmtpConnection(recipientEmail, member?.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error || 'SMTP test failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: `Test email sent successfully to ${recipientEmail}! Check your inbox.`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test S3/MinIO connection
|
||||||
|
testS3: async () => {
|
||||||
|
// Clear cache to ensure fresh settings are used
|
||||||
|
clearS3ClientCache();
|
||||||
|
|
||||||
|
// Test S3 connection using the actual client
|
||||||
|
const result = await testS3Connection();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error || 'S3 connection test failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: 'S3/MinIO connection successful! Bucket is accessible.'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test email template
|
||||||
|
testEmailTemplate: async ({ request, locals, url }) => {
|
||||||
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member?.email) {
|
||||||
|
return fail(400, { error: 'No email address found for your account' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const templateKey = formData.get('template_key') as string;
|
||||||
|
|
||||||
|
if (!templateKey) {
|
||||||
|
return fail(400, { error: 'Template key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full member details including member_id
|
||||||
|
const { data: fullMember } = await locals.supabase
|
||||||
|
.from('members')
|
||||||
|
.select('member_id')
|
||||||
|
.eq('id', member.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const memberId = fullMember?.member_id || 'MUSA-0001';
|
||||||
|
|
||||||
|
// Create sample variables for each template type
|
||||||
|
const sampleVariables: Record<string, Record<string, string>> = {
|
||||||
|
// Welcome/Auth templates
|
||||||
|
welcome: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
last_name: member.last_name || 'User',
|
||||||
|
member_id: memberId,
|
||||||
|
portal_url: url.origin
|
||||||
|
},
|
||||||
|
password_reset: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
reset_link: `${url.origin}/reset-password?token=sample-token`
|
||||||
|
},
|
||||||
|
email_verification: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
verification_link: `${url.origin}/verify?token=sample-token`
|
||||||
|
},
|
||||||
|
// Event templates
|
||||||
|
rsvp_confirmation: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
event_title: 'Monaco USA Annual Gala',
|
||||||
|
event_date: 'Saturday, March 15, 2026',
|
||||||
|
event_time: '7:00 PM',
|
||||||
|
event_location: 'Hotel Hermitage, Monaco',
|
||||||
|
guest_count: '2',
|
||||||
|
portal_url: `${url.origin}/events`
|
||||||
|
},
|
||||||
|
waitlist_promotion: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
event_title: 'Monaco USA Annual Gala',
|
||||||
|
event_date: 'Saturday, March 15, 2026',
|
||||||
|
event_location: 'Hotel Hermitage, Monaco',
|
||||||
|
portal_url: `${url.origin}/events`
|
||||||
|
},
|
||||||
|
event_reminder_24hr: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
event_title: 'Monaco USA Monthly Meetup',
|
||||||
|
event_date: 'Tomorrow, January 25, 2026',
|
||||||
|
event_time: '6:30 PM',
|
||||||
|
event_location: 'Stars\'n\'Bars, Monaco',
|
||||||
|
guest_count: '1',
|
||||||
|
portal_url: `${url.origin}/events/sample-event-id`
|
||||||
|
},
|
||||||
|
// Payment/Dues templates
|
||||||
|
payment_received: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
amount: '€50.00',
|
||||||
|
payment_date: 'January 24, 2026',
|
||||||
|
payment_method: 'Bank Transfer',
|
||||||
|
new_due_date: 'January 24, 2027',
|
||||||
|
member_id: memberId
|
||||||
|
},
|
||||||
|
dues_reminder_30: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
due_date: 'February 24, 2026',
|
||||||
|
amount: '€50.00',
|
||||||
|
member_id: memberId,
|
||||||
|
account_holder: 'Monaco USA Association',
|
||||||
|
bank_name: 'CMB Monaco',
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
},
|
||||||
|
dues_reminder_7: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
due_date: 'January 31, 2026',
|
||||||
|
amount: '€50.00',
|
||||||
|
member_id: memberId,
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
},
|
||||||
|
dues_reminder_1: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
due_date: 'January 25, 2026',
|
||||||
|
amount: '€50.00',
|
||||||
|
member_id: memberId,
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
},
|
||||||
|
dues_overdue: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
due_date: 'January 15, 2026',
|
||||||
|
amount: '€50.00',
|
||||||
|
days_overdue: '9',
|
||||||
|
grace_days_remaining: '21',
|
||||||
|
member_id: memberId,
|
||||||
|
account_holder: 'Monaco USA Association',
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
},
|
||||||
|
dues_grace_warning: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
due_date: 'December 24, 2025',
|
||||||
|
amount: '€50.00',
|
||||||
|
days_overdue: '31',
|
||||||
|
grace_days_remaining: '7',
|
||||||
|
grace_end_date: 'February 1, 2026',
|
||||||
|
member_id: memberId,
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
},
|
||||||
|
dues_inactive_notice: {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
amount: '€50.00',
|
||||||
|
member_id: memberId,
|
||||||
|
account_holder: 'Monaco USA Association',
|
||||||
|
iban: 'MC00 0000 0000 0000 0000 0000 000',
|
||||||
|
portal_url: `${url.origin}/payments`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get variables for this template, or use defaults
|
||||||
|
const variables = sampleVariables[templateKey] || {
|
||||||
|
first_name: member.first_name || 'Test',
|
||||||
|
last_name: member.last_name || 'User',
|
||||||
|
portal_url: url.origin
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send test email
|
||||||
|
const result = await sendTemplatedEmail(templateKey, member.email, variables, {
|
||||||
|
recipientId: member.id,
|
||||||
|
recipientName: `${member.first_name} ${member.last_name}`,
|
||||||
|
baseUrl: url.origin
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error || 'Failed to send test email' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: `Test email "${templateKey}" sent to ${member.email}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Poste Mail Server Actions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
testPoste: async ({ locals }) => {
|
||||||
|
// Get Poste settings
|
||||||
|
const { data: settings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'poste');
|
||||||
|
|
||||||
|
if (!settings || settings.length === 0) {
|
||||||
|
return fail(400, { error: 'Poste mail server not configured. Please save settings first.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings) {
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
config[s.setting_key] = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.poste_api_host || !config.poste_admin_email || !config.poste_admin_password) {
|
||||||
|
return fail(400, { error: 'Poste configuration incomplete. Host, admin email, and password are required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await poste.testConnection({
|
||||||
|
host: config.poste_api_host,
|
||||||
|
adminEmail: config.poste_admin_email,
|
||||||
|
adminPassword: config.poste_admin_password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error || 'Connection test failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: 'Connection to Poste mail server successful!' };
|
||||||
|
},
|
||||||
|
|
||||||
|
listMailboxes: async ({ locals }) => {
|
||||||
|
// Get Poste settings
|
||||||
|
const { data: settings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'poste');
|
||||||
|
|
||||||
|
if (!settings || settings.length === 0) {
|
||||||
|
return fail(400, { error: 'Poste not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings) {
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
config[s.setting_key] = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await poste.listMailboxes({
|
||||||
|
host: config.poste_api_host,
|
||||||
|
adminEmail: config.poste_admin_email,
|
||||||
|
adminPassword: config.poste_admin_password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mailboxes: result.mailboxes };
|
||||||
|
},
|
||||||
|
|
||||||
|
createMailbox: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const emailPrefix = formData.get('email_prefix') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
|
if (!emailPrefix || !displayName) {
|
||||||
|
return fail(400, { error: 'Email prefix and display name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Poste settings
|
||||||
|
const { data: settings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'poste');
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings || []) {
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
config[s.setting_key] = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = config.poste_domain || 'monacousa.org';
|
||||||
|
const fullEmail = `${emailPrefix}@${domain}`;
|
||||||
|
const actualPassword = password || poste.generatePassword();
|
||||||
|
|
||||||
|
const result = await poste.createMailbox(
|
||||||
|
{
|
||||||
|
host: config.poste_api_host,
|
||||||
|
adminEmail: config.poste_admin_email,
|
||||||
|
adminPassword: config.poste_admin_password
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: fullEmail,
|
||||||
|
name: displayName,
|
||||||
|
password: actualPassword
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: `Mailbox ${fullEmail} created successfully!`,
|
||||||
|
generatedPassword: password ? undefined : actualPassword
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMailbox: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const displayName = formData.get('display_name') as string;
|
||||||
|
const newPassword = formData.get('new_password') as string;
|
||||||
|
const disabled = formData.get('disabled') === 'true';
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return fail(400, { error: 'Email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Poste settings
|
||||||
|
const { data: settings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'poste');
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings || []) {
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
config[s.setting_key] = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: { name?: string; password?: string; disabled?: boolean } = {};
|
||||||
|
if (displayName) updates.name = displayName;
|
||||||
|
if (newPassword) updates.password = newPassword;
|
||||||
|
updates.disabled = disabled;
|
||||||
|
|
||||||
|
const result = await poste.updateMailbox(
|
||||||
|
{
|
||||||
|
host: config.poste_api_host,
|
||||||
|
adminEmail: config.poste_admin_email,
|
||||||
|
adminPassword: config.poste_admin_password
|
||||||
|
},
|
||||||
|
email,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: `Mailbox ${email} updated successfully!` };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMailbox: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return fail(400, { error: 'Email is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Poste settings
|
||||||
|
const { data: settings } = await locals.supabase
|
||||||
|
.from('app_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.eq('category', 'poste');
|
||||||
|
|
||||||
|
const config: Record<string, string> = {};
|
||||||
|
for (const s of settings || []) {
|
||||||
|
let value = s.setting_value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
value = value.replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
config[s.setting_key] = value as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await poste.deleteMailbox(
|
||||||
|
{
|
||||||
|
host: config.poste_api_host,
|
||||||
|
adminEmail: config.poste_admin_email,
|
||||||
|
adminPassword: config.poste_admin_password
|
||||||
|
},
|
||||||
|
email
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: `Mailbox ${email} deleted successfully!` };
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {};
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,719 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Upload,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
|
FolderOpen,
|
||||||
|
FolderPlus,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Filter,
|
||||||
|
ArrowUpRight
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll, goto } from '$app/navigation';
|
||||||
|
import {
|
||||||
|
DocumentPreviewModal,
|
||||||
|
FolderItem,
|
||||||
|
FolderBreadcrumbs,
|
||||||
|
CreateFolderModal
|
||||||
|
} from '$lib/components/documents';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
const { documents, folders, categories, member, currentFolderId, breadcrumbs } = data;
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let selectedCategory = $state<string | null>(null);
|
||||||
|
let showUploadModal = $state(false);
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let showCreateFolderModal = $state(false);
|
||||||
|
let showPreviewModal = $state(false);
|
||||||
|
let documentToDelete = $state<any>(null);
|
||||||
|
let documentToPreview = $state<any>(null);
|
||||||
|
let previewUrl = $state<string>('');
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let selectedFile = $state<File | null>(null);
|
||||||
|
let dragOver = $state(false);
|
||||||
|
let renamingFolder = $state<any>(null);
|
||||||
|
let showRenameFolderModal = $state(false);
|
||||||
|
|
||||||
|
// Filter documents
|
||||||
|
const filteredDocuments = $derived(
|
||||||
|
(documents || []).filter((doc: any) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery ||
|
||||||
|
doc.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
doc.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesCategory = !selectedCategory || doc.category_id === selectedCategory;
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter folders
|
||||||
|
const filteredFolders = $derived(
|
||||||
|
(folders || []).filter((folder: any) => {
|
||||||
|
if (!searchQuery) return true;
|
||||||
|
return folder.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canEdit = $derived(member?.role === 'board' || member?.role === 'admin');
|
||||||
|
const canDelete = $derived(member?.role === 'admin');
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get visibility label
|
||||||
|
function getVisibilityLabel(visibility: string) {
|
||||||
|
switch (visibility) {
|
||||||
|
case 'public':
|
||||||
|
return { label: 'Public', color: 'bg-green-100 text-green-700' };
|
||||||
|
case 'members':
|
||||||
|
return { label: 'Members', color: 'bg-blue-100 text-blue-700' };
|
||||||
|
case 'board':
|
||||||
|
return { label: 'Board', color: 'bg-purple-100 text-purple-700' };
|
||||||
|
case 'admin':
|
||||||
|
return { label: 'Admin', color: 'bg-red-100 text-red-700' };
|
||||||
|
default:
|
||||||
|
return { label: visibility, color: 'bg-slate-100 text-slate-700' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
selectedFile = input.files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drag and drop
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
|
||||||
|
selectedFile = e.dataTransfer.files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to folder
|
||||||
|
function navigateToFolder(folderId: string | null) {
|
||||||
|
if (folderId) {
|
||||||
|
goto(`/board/documents?folder=${folderId}`);
|
||||||
|
} else {
|
||||||
|
goto('/board/documents');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm delete
|
||||||
|
function confirmDelete(doc: any) {
|
||||||
|
documentToDelete = doc;
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open preview
|
||||||
|
async function openPreview(doc: any) {
|
||||||
|
documentToPreview = doc;
|
||||||
|
// Get the preview URL (uses active_url which is computed based on storage setting)
|
||||||
|
previewUrl = doc.active_url || doc.file_path;
|
||||||
|
showPreviewModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset upload form
|
||||||
|
function resetUploadForm() {
|
||||||
|
selectedFile = null;
|
||||||
|
showUploadModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle folder rename
|
||||||
|
function handleRenameFolder(folder: any) {
|
||||||
|
renamingFolder = folder;
|
||||||
|
showRenameFolderModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle folder delete
|
||||||
|
function handleDeleteFolder(folder: any) {
|
||||||
|
documentToDelete = { ...folder, isFolder: true };
|
||||||
|
showDeleteConfirm = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Document Management | Monaco USA</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">Document Management</h1>
|
||||||
|
<p class="text-slate-500">Upload and manage association documents</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
onclick={() => (showCreateFolderModal = true)}
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<FolderPlus class="h-5 w-5" />
|
||||||
|
New Folder
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => (showUploadModal = true)}
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 font-medium text-white hover:bg-monaco-700"
|
||||||
|
>
|
||||||
|
<Upload class="h-5 w-5" />
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="rounded-lg bg-green-50 p-4 text-sm text-green-600">
|
||||||
|
{form.success}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
{#if breadcrumbs && breadcrumbs.length > 0}
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<FolderBreadcrumbs {breadcrumbs} onNavigate={navigateToFolder} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="glass-card p-4">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search documents and folders..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="h-10 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
bind:value={selectedCategory}
|
||||||
|
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value={null}>All Categories</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category.id}>{category.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folders Grid -->
|
||||||
|
{#if filteredFolders.length > 0}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-3 text-sm font-medium text-slate-500 uppercase tracking-wide">Folders</h2>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{#each filteredFolders as folder}
|
||||||
|
<FolderItem
|
||||||
|
{folder}
|
||||||
|
{canEdit}
|
||||||
|
{canDelete}
|
||||||
|
onNavigate={navigateToFolder}
|
||||||
|
onRename={handleRenameFolder}
|
||||||
|
onDelete={handleDeleteFolder}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Documents Table -->
|
||||||
|
<div class="glass-card overflow-hidden">
|
||||||
|
{#if filteredFolders.length === 0 && filteredDocuments.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||||
|
<FolderOpen class="mb-4 h-16 w-16 text-slate-300" />
|
||||||
|
<h3 class="text-lg font-medium text-slate-900">
|
||||||
|
{currentFolderId ? 'This folder is empty' : 'No documents found'}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-slate-500">
|
||||||
|
{searchQuery || selectedCategory
|
||||||
|
? 'Try adjusting your search or filters.'
|
||||||
|
: currentFolderId
|
||||||
|
? 'Upload documents or create subfolders to get started.'
|
||||||
|
: 'Upload your first document to get started.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if filteredDocuments.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<FileText class="mb-4 h-12 w-12 text-slate-300" />
|
||||||
|
<h3 class="text-base font-medium text-slate-900">No documents in this location</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
Documents you upload here will appear in this list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<h2 class="px-6 py-3 text-sm font-medium text-slate-500 uppercase tracking-wide bg-slate-50/50">
|
||||||
|
Documents ({filteredDocuments.length})
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||||
|
Document
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||||
|
Category
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||||
|
Visibility
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium uppercase text-slate-500">
|
||||||
|
Uploaded
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium uppercase text-slate-500">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{#each filteredDocuments as doc}
|
||||||
|
{@const visInfo = getVisibilityLabel(doc.visibility)}
|
||||||
|
<tr class="hover:bg-slate-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onclick={() => openPreview(doc)}
|
||||||
|
class="flex items-center gap-3 text-left hover:text-monaco-600 transition-colors"
|
||||||
|
>
|
||||||
|
<FileText class="h-5 w-5 text-monaco-600 flex-shrink-0" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-medium text-slate-900 truncate">{doc.title}</p>
|
||||||
|
<p class="text-xs text-slate-500 truncate">
|
||||||
|
{doc.file_name} ({formatFileSize(doc.file_size)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-slate-500">
|
||||||
|
{doc.category?.display_name || '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateVisibility"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="document_id" value={doc.id} />
|
||||||
|
<select
|
||||||
|
name="visibility"
|
||||||
|
value={doc.visibility}
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
class="rounded-lg border-0 bg-transparent py-1 pr-8 text-xs font-medium cursor-pointer hover:bg-slate-100 {visInfo.color}"
|
||||||
|
>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="members">Members</option>
|
||||||
|
<option value="board">Board</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="text-slate-900">{formatDate(doc.created_at)}</p>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
by {doc.uploader?.first_name} {doc.uploader?.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onclick={() => openPreview(doc)}
|
||||||
|
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={doc.active_url || doc.file_path}
|
||||||
|
download={doc.file_name}
|
||||||
|
class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-monaco-600"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download class="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
{#if canDelete}
|
||||||
|
<button
|
||||||
|
onclick={() => confirmDelete(doc)}
|
||||||
|
class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Folder Modal -->
|
||||||
|
{#if showCreateFolderModal}
|
||||||
|
<CreateFolderModal
|
||||||
|
parentFolderId={currentFolderId}
|
||||||
|
onClose={() => (showCreateFolderModal = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Document Preview Modal -->
|
||||||
|
{#if showPreviewModal && documentToPreview}
|
||||||
|
<DocumentPreviewModal
|
||||||
|
document={documentToPreview}
|
||||||
|
{previewUrl}
|
||||||
|
onClose={() => {
|
||||||
|
showPreviewModal = false;
|
||||||
|
documentToPreview = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
{#if showUploadModal}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="glass-card w-full max-w-lg p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Upload Document</h2>
|
||||||
|
<button onclick={resetUploadForm} class="rounded p-1 hover:bg-slate-100">
|
||||||
|
<X class="h-5 w-5 text-slate-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/upload"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSubmitting = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
isSubmitting = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
resetUploadForm();
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<!-- Hidden folder ID -->
|
||||||
|
{#if currentFolderId}
|
||||||
|
<input type="hidden" name="folder_id" value={currentFolderId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- File Drop Zone -->
|
||||||
|
<div
|
||||||
|
class="relative rounded-lg border-2 border-dashed p-8 text-center transition-colors
|
||||||
|
{dragOver
|
||||||
|
? 'border-monaco-500 bg-monaco-50'
|
||||||
|
: 'border-slate-300 hover:border-slate-400'}"
|
||||||
|
ondrop={handleDrop}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
id="file"
|
||||||
|
class="absolute inset-0 cursor-pointer opacity-0"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.jpg,.jpeg,.png,.webp,.gif"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if selectedFile}
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<FileText class="h-10 w-10 text-monaco-600" />
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="font-medium text-slate-900">{selectedFile.name}</p>
|
||||||
|
<p class="text-sm text-slate-500">{formatFileSize(selectedFile.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectedFile = null;
|
||||||
|
}}
|
||||||
|
class="rounded p-1 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Upload class="mx-auto h-10 w-10 text-slate-400" />
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
Drag and drop or click to select
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, GIF (max 50MB)
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="title">Title *</Label>
|
||||||
|
<Input type="text" id="title" name="title" required class="mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="2"
|
||||||
|
class="mt-1 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
|
||||||
|
placeholder="Brief description of the document..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label for="category_id">Category</Label>
|
||||||
|
<select
|
||||||
|
id="category_id"
|
||||||
|
name="category_id"
|
||||||
|
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select category...</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category.id}>{category.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="visibility">Visibility</Label>
|
||||||
|
<select
|
||||||
|
id="visibility"
|
||||||
|
name="visibility"
|
||||||
|
class="mt-1 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="members">Members Only</option>
|
||||||
|
<option value="board">Board Only</option>
|
||||||
|
<option value="admin">Admin Only</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetUploadForm}
|
||||||
|
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !selectedFile}
|
||||||
|
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
></div>
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
<Upload class="h-4 w-4" />
|
||||||
|
Upload Document
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteConfirm && documentToDelete}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="glass-card w-full max-w-md p-6">
|
||||||
|
<div class="mb-4 flex items-center gap-3 text-red-600">
|
||||||
|
<Trash2 class="h-6 w-6" />
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-4 text-slate-600">
|
||||||
|
Are you sure you want to delete <strong>{documentToDelete.title || documentToDelete.name}</strong>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
documentToDelete = null;
|
||||||
|
}}
|
||||||
|
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={documentToDelete.isFolder ? '?/deleteFolder' : '?/delete'}
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showDeleteConfirm = false;
|
||||||
|
documentToDelete = null;
|
||||||
|
}
|
||||||
|
await invalidateAll();
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={documentToDelete.isFolder ? 'folder_id' : 'document_id'}
|
||||||
|
value={documentToDelete.id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Rename Folder Modal -->
|
||||||
|
{#if showRenameFolderModal && renamingFolder}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="glass-card w-full max-w-md p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Rename Folder</h2>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showRenameFolderModal = false;
|
||||||
|
renamingFolder = null;
|
||||||
|
}}
|
||||||
|
class="rounded p-1 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5 text-slate-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/renameFolder"
|
||||||
|
use:enhance={() => {
|
||||||
|
isSubmitting = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
isSubmitting = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
showRenameFolderModal = false;
|
||||||
|
renamingFolder = null;
|
||||||
|
}
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="folder_id" value={renamingFolder.id} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for="rename-name">Folder Name</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="rename-name"
|
||||||
|
name="name"
|
||||||
|
value={renamingFolder.name}
|
||||||
|
required
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showRenameFolderModal = false;
|
||||||
|
renamingFolder = null;
|
||||||
|
}}
|
||||||
|
class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
class="flex flex-1 items-center justify-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||||
|
Saving...
|
||||||
|
{:else}
|
||||||
|
Save
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue