Compare commits

...

No commits in common. "main" and "visual-audit/playwright" have entirely different histories.

661 changed files with 119961 additions and 54961 deletions

View File

@ -0,0 +1,66 @@
{
"mcpServers": {
"serena": {
"type": "stdio",
"command": "uvx",
"args": [
"--from",
"git+https://github.com/oraios/serena",
"serena",
"start-mcp-server",
"--context",
"ide-assistant",
"--project",
"${workspaceFolder}"
],
"env": {}
},
"zen": {
"type": "stdio",
"command": "pwsh",
"args": [
"-NoLogo",
"-NoProfile",
"-Command",
"$p=(Get-Command uvx -ErrorAction SilentlyContinue).Source; if(-not $p){$c=@(\"$HOME\\.local\\bin\\uvx.exe\",\"C:\\\\Program Files\\\\uv\\\\bin\\\\uvx.exe\"); foreach($i in $c){ if(Test-Path $i){$p=$i; break}}}; if($p){ & $p --from git+https://github.com/BeehiveInnovations/zen-mcp-server.git zen-mcp-server } else { Write-Error 'uvx not found'; exit 1 }"
],
"env": {
"GEMINI_API_KEY": "your_gemini_key",
"OPENAI_API_KEY": "your_openai_key"
}
},
"playwright": {
"type": "stdio",
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@playwright/mcp@latest"
],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp@latest"
],
"env": {}
},
"@21st-dev/magic": {
"type": "stdio",
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@21st-dev/magic@latest"
],
"env": {
"API_KEY": "adb246737aabae0b2f124fc85dc03737a0f65d9660b786732c31578649da10e5"
}
}
},
}

101
.claude/settings.local.json Normal file
View File

@ -0,0 +1,101 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Read(C:\\Users\\mpcia/**)",
"Read(C:\\Users\\mpcia/**)",
"mcp__serena__activate_project",
"mcp__serena__list_dir",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_take_screenshot",
"mcp__playwright__browser_snapshot",
"mcp__playwright__browser_type",
"mcp__playwright__browser_click",
"mcp__playwright__browser_press_key",
"mcp__playwright__browser_wait_for",
"mcp__serena__find_symbol",
"mcp__serena__search_for_pattern",
"mcp___21st-dev_magic__21st_magic_component_inspiration",
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git push:*)",
"Bash(git commit:*)",
"Bash(npm run build:*)",
"mcp__serena__find_file",
"mcp___21st-dev_magic__21st_magic_component_builder",
"Bash(npm run dev:*)",
"Bash(New-Item -Path \"Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard\\index.vue\" -ItemType File -Force)",
"Bash(grep:*)",
"Bash(findstr:*)",
"mcp__playwright__browser_close",
"Bash(dir:*)",
"mcp__playwright__browser_evaluate",
"mcp__playwright__browser_hover",
"mcp__playwright__browser_resize",
"mcp__playwright__browser_console_messages",
"mcp__serena__check_onboarding_performed",
"mcp__serena__get_symbols_overview",
"mcp__serena__find_referencing_symbols",
"mcp__zen__thinkdeep",
"mcp__serena__insert_after_symbol",
"mcp__serena__replace_symbol_body",
"mcp__playwright__browser_fill_form",
"mcp__zen__debug",
"Bash(Copy-Item -Path \"Z:\\Repos\\monacousa-portal\\design-mockups\\pages\\auth\\ProfessionalLogin.vue\" -Destination \"Z:\\Repos\\monacousa-portal\\pages\\mockups\\login.vue\")",
"Bash(Remove-Item -Path \"Z:\\Repos\\monacousa-portal\\pages\\mockups\" -Recurse -Force)",
"mcp__zen__analyze",
"Read(/Z:\\Repos\\monacousa-portal\\.playwright-mcp/**)",
"Read(/Z:\\Repos\\monacousa-portal\\.playwright-mcp/**)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Bash(New-Item -Path \"Z:\\Repos\\monacousa-portal\\assets\\scss\\design-system-v2.scss\" -ItemType File -Force)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Read(/Z:\\Repos\\monacousa-portal/**)",
"Read(/Z:\\Repos\\monacousa-portal/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components\\ui/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components\\ui/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\board/**)",
"Read(/Z:\\Repos\\monacousa-portal\\assets\\scss/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\dashboard/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\settings/**)",
"Bash(gh run list:*)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\layouts/**)",
"Read(/Z:\\Repos\\monacousa-portal\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\api\\members\\[id]/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\components/**)",
"Read(/Z:\\Repos\\monacousa-portal\\server\\api/**)",
"Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)",
"Bash(git pull:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -0,0 +1,339 @@
# MonacoUSA Portal - Cline Workspace Rules
## Project Overview
This is the **MonacoUSA Portal** - a modern, responsive web portal built with Nuxt 3, Vuetify 3, and Keycloak authentication. The portal provides a unified dashboard for tools and services with mobile-first design and PWA capabilities.
## Tech Stack & Architecture
### Core Technologies
- **Framework**: Nuxt 3 with Vue 3 (SPA mode)
- **UI Library**: Vuetify 3 with MonacoUSA theme (#a31515 primary color)
- **Authentication**: Keycloak (OAuth2/OIDC)
- **Database**: NocoDB (API-first database)
- **File Storage**: MinIO (S3-compatible object storage)
- **PWA**: Vite PWA plugin with offline support
- **TypeScript**: Full TypeScript support throughout
### Project Structure
```
monacousa-portal/
├── components/ # Vue components
├── composables/ # Vue composables (useAuth, etc.)
├── layouts/ # Nuxt layouts (dashboard layout)
├── middleware/ # Route middleware (auth middleware)
├── pages/ # Application pages
│ ├── auth/ # Authentication pages
│ └── dashboard/ # Dashboard pages
├── plugins/ # Nuxt plugins
├── public/ # Static assets
├── server/ # Server-side code
│ ├── api/ # API routes
│ ├── utils/ # Server utilities
│ └── plugins/ # Server plugins
├── utils/ # Shared utilities
└── docs/ # Documentation
```
## Development Guidelines
### 1. Authentication System
- Uses Keycloak OAuth2/OIDC flow
- Session management with encrypted cookies
- `useAuth()` composable for authentication state
- Middleware protection for authenticated routes
- Support for user groups/roles (admin, manager, etc.)
### 2. API Structure
- RESTful APIs in `server/api/`
- Database operations via NocoDB client
- File operations via MinIO client
- Consistent error handling and responses
- Health check endpoint at `/api/health`
### 3. UI/UX Standards
- Mobile-first responsive design
- Vuetify 3 components with MonacoUSA theme
- Dashboard layout with collapsible sidebar
- PWA support with install prompts
- Consistent color scheme: #a31515 (primary), #ffffff (secondary)
### 4. File Organization
- Components in `components/` directory
- Composables for reusable logic
- Server utilities in `server/utils/`
- TypeScript types in `utils/types.ts`
- Environment configuration in `.env` files
## Coding Standards
### 1. Vue/Nuxt Patterns
```typescript
// Use composition API with <script setup>
<script setup lang="ts">
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { user, isAdmin } = useAuth();
</script>
```
### 2. API Route Patterns
```typescript
// server/api/example.ts
export default defineEventHandler(async (event) => {
try {
// Implementation
return { success: true, data: result };
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Operation failed'
});
}
});
```
### 3. Database Operations
```typescript
// Use NocoDB client
const nocodb = createNocoDBClient();
const records = await nocodb.findAll('tableName', { limit: 10 });
```
### 4. File Storage
```typescript
// Use MinIO client
const minio = createMinIOClient();
await minio.uploadFile(fileName, buffer, contentType);
```
## Environment Configuration
### Required Environment Variables
```env
# Keycloak Configuration
NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa-portal
NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal
NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
NUXT_KEYCLOAK_CALLBACK_URL=https://monacousa.org/auth/callback
# NocoDB Configuration
NUXT_NOCODB_URL=https://db.monacousa.org
NUXT_NOCODB_TOKEN=your-nocodb-token
NUXT_NOCODB_BASE_ID=your-nocodb-base-id
# MinIO Configuration
NUXT_MINIO_ENDPOINT=s3.monacousa.org
NUXT_MINIO_PORT=443
NUXT_MINIO_USE_SSL=true
NUXT_MINIO_ACCESS_KEY=your-minio-access-key
NUXT_MINIO_SECRET_KEY=your-minio-secret-key
NUXT_MINIO_BUCKET_NAME=monacousa-portal
# Security Configuration
NUXT_SESSION_SECRET=your-48-character-session-secret-key-here
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here
# Public Configuration
NUXT_PUBLIC_DOMAIN=monacousa.org
```
## Key Features & Capabilities
### 1. Authentication Flow
- OAuth2/OIDC with Keycloak
- Secure session management
- Role-based access control
- Automatic token refresh
- Logout functionality
### 2. Dashboard System
- Responsive sidebar navigation
- Role-based menu items
- PWA install prompts
- Mobile-optimized layout
- User profile display
### 3. File Management
- Upload/download via MinIO
- File type validation
- Progress indicators
- Error handling
- Secure file access
### 4. Database Integration
- CRUD operations via NocoDB
- Dynamic table access
- Query parameters support
- Error handling
- Type safety
### 5. PWA Features
- Offline support
- Install prompts
- Service worker
- App manifest
- Mobile optimization
## Development Commands
```bash
# Development
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Type checking
npm run typecheck
```
## Testing & Health Checks
### Health Check Endpoint
- `GET /api/health` - System health status
- Checks database, storage, and auth connectivity
- Returns status: healthy/degraded/unhealthy
### Manual Testing
1. Authentication flow (login/logout)
2. Dashboard navigation
3. File upload/download
4. Database operations
5. Mobile responsiveness
6. PWA installation
## Security Considerations
### 1. Session Security
- Encrypted session cookies
- Secure cookie settings
- HTTPS required in production
- Session timeout handling
### 2. API Security
- Authentication middleware
- Input validation
- Error message sanitization
- CORS configuration
### 3. File Security
- File type validation
- Size limits
- Secure storage
- Access control
## Deployment Guidelines
### 1. Production Requirements
- Node.js 18+
- SSL certificate
- Reverse proxy (nginx)
- Environment variables configured
### 2. Build Process
```bash
npm ci --only=production
npm run build
npm run preview
```
### 3. Health Monitoring
- Monitor `/api/health` endpoint
- Check service dependencies
- Monitor error logs
- Performance metrics
## Extension Guidelines
### Adding New Tools
1. Create page in `pages/dashboard/`
2. Add navigation item to dashboard layout
3. Implement API routes if needed
4. Add database tables in NocoDB
5. Update TypeScript types
6. Test authentication and permissions
### Adding New APIs
1. Create route in `server/api/`
2. Implement proper error handling
3. Add authentication if required
4. Update TypeScript types
5. Test with health checks
### Adding New Components
1. Create in `components/` directory
2. Follow Vuetify patterns
3. Ensure mobile responsiveness
4. Add proper TypeScript types
5. Test across devices
## Troubleshooting
### Common Issues
1. **Authentication failures**: Check Keycloak configuration
2. **Database errors**: Verify NocoDB connection and tokens
3. **File upload issues**: Check MinIO configuration and permissions
4. **Build errors**: Verify all environment variables are set
5. **Mobile issues**: Test responsive design and PWA features
### Debug Tools
- Browser developer tools
- Network tab for API calls
- Console for JavaScript errors
- Health check endpoint
- Server logs
## Best Practices
### 1. Code Quality
- Use TypeScript throughout
- Follow Vue 3 composition API patterns
- Implement proper error handling
- Write descriptive commit messages
- Keep components focused and reusable
### 2. Performance
- Lazy load components where appropriate
- Optimize images and assets
- Use proper caching strategies
- Monitor bundle size
- Implement proper loading states
### 3. Security
- Validate all inputs
- Use HTTPS in production
- Keep dependencies updated
- Follow OWASP guidelines
- Regular security audits
### 4. Maintainability
- Document complex logic
- Use consistent naming conventions
- Keep functions small and focused
- Separate concerns properly
- Regular code reviews
## Support & Resources
### Documentation
- Implementation guide: `MONACOUSA_PORTAL_IMPLEMENTATION.md`
- Nuxt 3 documentation
- Vuetify 3 documentation
- Keycloak documentation
- NocoDB API documentation
### Key Files to Reference
- `nuxt.config.ts` - Main configuration
- `server/utils/keycloak.ts` - Authentication logic
- `composables/useAuth.ts` - Auth composable
- `layouts/dashboard.vue` - Main layout
- `utils/types.ts` - TypeScript definitions
This workspace is designed to be a solid foundation for building custom tools and features while maintaining consistency, security, and performance standards.

View File

@ -1,47 +1,133 @@
# Dependencies # Node.js
node_modules node_modules
.pnpm-store npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output # Nuxt.js build output
build .output
.svelte-kit .nuxt
.nitro
.cache
dist
# Environment files (we pass these at runtime) # Environment files
.env .env
.env.* .env.*
!.env.example !.env.example
# Logs
*.log
logs
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git # Git
.git .git
.gitignore .gitignore
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Test
coverage
.nyc_output
# Docker # Docker
Dockerfile* Dockerfile*
docker-compose*.yml docker-compose*
.dockerignore .dockerignore
# Supabase local # CI/CD
supabase/.temp .github/
supabase/.branches .gitea/
# Misc # Documentation
README.md
docs/
*.md *.md
LICENSE
# Test files
test/
tests/
__tests__/
*.test.js
*.spec.js
# Development files
.editorconfig
.eslintrc*
.prettierrc*
jest.config.js
cypress.json
# Temporary files
tmp/
temp/
# Local data directories
data/
logs/
nginx/

View File

@ -1,89 +1,39 @@
# Monaco USA Portal - Docker Environment Configuration # Example Environment
# ===================================================
# Copy this file to .env and configure your values
# =========================================== # Server Configuration
# POSTGRES DATABASE NUXT_PORT=6060
# =========================================== NUXT_HOST=0.0.0.0
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-this-to-a-secure-password
POSTGRES_DB=postgres
POSTGRES_PORT=5435
# =========================================== # Keycloak Configuration
# JWT CONFIGURATION NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa
# =========================================== NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal
# IMPORTANT: Generate a new secret for production! NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
# Use: openssl rand -base64 32 NUXT_KEYCLOAK_CALLBACK_URL=https://portal.monacousa.org/auth/callback
JWT_SECRET=generate-a-new-secret-at-least-32-characters
JWT_EXPIRY=3600
# =========================================== # Keycloak Admin Configuration (for password reset and admin operations)
# API KEYS NUXT_KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
# =========================================== NUXT_KEYCLOAK_ADMIN_CLIENT_SECRET=your-admin-cli-client-secret
# 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) # Cookie Configuration
ANON_KEY=your-generated-anon-key COOKIE_DOMAIN=.monacousa.org
# Service role key - for admin access (full permissions, keep secret!) # NocoDB Configuration
SERVICE_ROLE_KEY=your-generated-service-role-key NUXT_NOCODB_URL=https://db.monacousa.org
NUXT_NOCODB_TOKEN=your-nocodb-token
NUXT_NOCODB_BASE_ID=your-nocodb-base-id
# =========================================== # MinIO Configuration
# URLS & PORTS NUXT_MINIO_ENDPOINT=s3.monacousa.org
# =========================================== NUXT_MINIO_PORT=443
KONG_HTTP_PORT=7455 NUXT_MINIO_USE_SSL=true
KONG_HTTPS_PORT=7456 NUXT_MINIO_ACCESS_KEY=your-minio-access-key
STUDIO_PORT=7454 NUXT_MINIO_SECRET_KEY=your-minio-secret-key
PORTAL_PORT=7453 NUXT_MINIO_BUCKET_NAME=monacousa-portal
SITE_URL=http://localhost:7453 # Security Configuration
API_EXTERNAL_URL=http://localhost:7455 NUXT_SESSION_SECRET=your-48-character-session-secret-key-here
SUPABASE_PUBLIC_URL=http://localhost:7455 NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here
PUBLIC_SUPABASE_URL=http://localhost:7455 # Public Configuration
PUBLIC_SUPABASE_ANON_KEY=same-as-anon-key-above NUXT_PUBLIC_DOMAIN=https://portal.monacousa.org
#
# 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

View File

@ -1,130 +0,0 @@
# 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

View File

@ -0,0 +1,25 @@
name: Build And Push Image
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Login To Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set Up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build And Push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
push: true
tags: |
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:latest

View File

@ -1,130 +0,0 @@
# 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

49
.gitignore vendored
View File

@ -1,23 +1,44 @@
# Nuxt dev/build outputs
.output
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules node_modules
# Output # Logs
.output *.log*
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS # Misc
.DS_Store .DS_Store
Thumbs.db .fleet
.idea
# Env # Local env files
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.test !.env.docker
# Vite # Editor directories and files
vite.config.js.timestamp-* .vscode/*
vite.config.ts.timestamp-* !.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local data directories
data/
logs/
# Debug files and troubleshooting artifacts
debug-*.js
*.debug.log
LOGIN_FIX_*.md
CUSTOM_*_IMPLEMENTATION.md
troubleshooting/
sequential-thinking/

1
.npmrc
View File

@ -1 +0,0 @@
engine-strict=true

1
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/cache

Binary file not shown.

68
.serena/project.yml Normal file
View File

@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "monacousa-portal"

File diff suppressed because it is too large Load Diff

View File

@ -1,230 +0,0 @@
# 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)

535
DOCKER_DEPLOYMENT_GUIDE.md Normal file
View File

@ -0,0 +1,535 @@
# MonacoUSA Portal - Docker & CI/CD Deployment Guide
This guide covers the complete Docker containerization and Gitea CI/CD setup for the MonacoUSA Portal.
## 📋 Overview
The deployment setup includes:
- **Multi-stage Docker build** for optimized production images
- **Docker Compose** for local development and deployment
- **Gitea Actions CI/CD** pipeline with staging and production environments
- **Health checks** and monitoring
- **Volume management** for persistent data
- **Zero-downtime deployments**
## 🐳 Docker Configuration
### Files Included
1. **`Dockerfile`** - Multi-stage build configuration
2. **`docker-compose.yml`** - Local development and deployment
3. **`docker-entrypoint.sh`** - Runtime configuration script
4. **`.dockerignore`** - Build context optimization
5. **`.env.docker`** - Environment variables template
### Docker Features
- **Multi-stage build**: Optimized for production (reduces image size from ~1GB to ~200MB)
- **Non-root user**: Runs as `nuxt` user for security
- **Health checks**: Built-in health monitoring using `/api/health` endpoint
- **Signal handling**: Proper shutdown with dumb-init
- **Volume support**: Persistent data storage
## 🚀 Quick Start
### 1. Local Development
```bash
# Clone your MonacoUSA Portal repository
git clone <your-repo-url>
cd monacousa-portal
# Copy Docker files from foundation
cp monacousa-portal-foundation/* .
# Create environment file
cp .env.docker .env
# Edit .env with your actual values
# Create data directory
mkdir -p data logs
# Build and run
docker-compose up -d
# Check health
curl http://localhost:3000/api/health
```
### 2. Production Deployment
```bash
# On your server
mkdir -p /opt/monacousa-portal
cd /opt/monacousa-portal
# Copy deployment files
# (docker-compose.yml, .env, etc.)
# Create required directories
mkdir -p data logs nginx/ssl
# Deploy
docker-compose up -d
# Verify
curl https://monacousa.org/api/health
```
## 🔧 Environment Configuration
### Required Environment Variables
Copy `.env.docker` to `.env` and configure:
```bash
# Generate secure keys
openssl rand -base64 48 # For NUXT_SESSION_SECRET
openssl rand -base64 32 # For NUXT_ENCRYPTION_KEY
# Update all placeholder values with your actual configuration
```
### Key Configuration Sections
1. **Keycloak Authentication**
- Issuer URL
- Client ID and secret
- Callback URL
2. **NocoDB Database**
- API URL and token
- Base ID
3. **MinIO File Storage**
- Endpoint and credentials
- Bucket name
4. **Security**
- Session secret
- Encryption key
## 📁 Volume Management
### Volume Structure
```
/opt/monacousa-portal/
├── data/ # Persistent application data
│ ├── .env # Environment configuration
│ └── uploads/ # File uploads (if local storage used)
├── logs/ # Application and nginx logs
│ ├── app/
│ └── nginx/
└── nginx/ # Nginx configuration
├── nginx.conf
└── ssl/
```
### Volume Configuration
The Docker setup includes volumes for:
- **Configuration**: `.env` file and settings
- **Logs**: Application and web server logs
- **Data**: Any persistent application data
## 🔄 CI/CD Pipeline (Gitea Actions)
### Workflow Overview
The `.gitea/workflows/deploy.yml` provides:
1. **Test Stage**
- Dependency installation
- Linting and type checking
- Build verification
- Health endpoint testing
2. **Build Stage**
- Docker build and push to registry
- Uses Gitea variables for registry configuration
- Tags with branch name and latest
3. **Deploy Stages**
- **Staging**: Automatic deployment on `develop` branch
- **Production**: Automatic deployment on `main` branch
- Zero-downtime deployments
- Health check verification
4. **Notification Stage**
- Success/failure notifications
- Webhook support
### Required Gitea Configuration
#### Variables (Repository Settings > Actions > Variables)
- `REGISTRY_HOST` - Docker registry hostname (e.g., `registry.monacousa.org`)
- `REGISTRY_USERNAME` - Registry username
- `IMAGE_NAME` - Docker image name (e.g., `monacousa-portal`)
#### Secrets (Repository Settings > Actions > Secrets)
- `REGISTRY_TOKEN` - Registry authentication token
#### Deployment Secrets (if using deployment stages)
- `STAGING_HOST` - Staging server hostname
- `STAGING_USER` - SSH username for staging
- `STAGING_SSH_KEY` - SSH private key for staging
- `STAGING_PORT` - SSH port (optional, defaults to 22)
- `PRODUCTION_HOST` - Production server hostname
- `PRODUCTION_USER` - SSH username for production
- `PRODUCTION_SSH_KEY` - SSH private key for production
- `PRODUCTION_PORT` - SSH port (optional, defaults to 22)
### Workflow Features
- **Automatic builds** on push to main/develop branches
- **Multi-platform support** (linux/amd64)
- **Branch-based tagging** (latest for main, branch name for others)
- **Health check verification** before and after deployment
- **Rollback capability** with image backups
- **Clean up** of old Docker images
## 🏗️ Server Setup
### Prerequisites
```bash
# Install Docker and Docker Compose
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Install Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# Create deployment user
sudo useradd -m -s /bin/bash deploy
sudo usermod -aG docker deploy
# Setup SSH key for deployment
sudo -u deploy mkdir -p /home/deploy/.ssh
# Add your public key to /home/deploy/.ssh/authorized_keys
```
### Directory Structure
```bash
# Create application directories
sudo mkdir -p /opt/monacousa-portal
sudo mkdir -p /opt/monacousa-portal-staging
sudo chown -R deploy:deploy /opt/monacousa-portal*
# Create data directories
sudo -u deploy mkdir -p /opt/monacousa-portal/{data,logs}
sudo -u deploy mkdir -p /opt/monacousa-portal-staging/{data,logs}
```
### Server-Level Nginx Configuration
The included `nginx-portal.conf` file is a reference configuration for your server-level nginx setup. Configure nginx on your server to:
- **Reverse proxy** to the Docker container on port 6060
- **SSL termination** with your certificates
- **Security headers** and optimizations
- **Static file serving** if needed
Example server nginx configuration:
```nginx
server {
listen 443 ssl http2;
server_name portal.monacousa.org;
# SSL configuration
ssl_certificate /path/to/your/cert.pem;
ssl_certificate_key /path/to/your/key.pem;
# Proxy to Docker container
location / {
proxy_pass http://localhost:6060;
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;
}
}
```
## 🔍 Health Checks & Monitoring
### Built-in Health Checks
The application includes comprehensive health checks:
```bash
# Docker health check
docker ps # Shows health status
# Manual health check
curl http://localhost:3000/api/health
# Expected response
{
"status": "healthy",
"timestamp": "2025-01-06T12:00:00.000Z",
"services": {
"database": "connected",
"storage": "connected",
"auth": "connected"
}
}
```
### Monitoring Commands
```bash
# View container logs
docker-compose logs -f monacousa-portal
# Check container status
docker-compose ps
# View resource usage
docker stats monacousa-portal
# Check health endpoint
watch -n 5 'curl -s http://localhost:3000/api/health | jq'
```
## 🔄 Deployment Operations
### Manual Deployment
```bash
# Pull latest image
docker pull registry.monacousa.org/monacousa/monacousa-portal:latest
# Update and restart
docker-compose up -d --no-deps monacousa-portal
# Verify deployment
curl -f https://monacousa.org/api/health
```
### Rollback Procedure
```bash
# List available images
docker images registry.monacousa.org/monacousa/monacousa-portal
# Rollback to previous version
docker tag registry.monacousa.org/monacousa/monacousa-portal:backup-20250106-120000 registry.monacousa.org/monacousa/monacousa-portal:latest
docker-compose up -d --no-deps monacousa-portal
# Verify rollback
curl -f https://monacousa.org/api/health
```
### Backup Procedures
```bash
# Backup environment configuration
cp data/.env data/.env.backup.$(date +%Y%m%d-%H%M%S)
# Backup logs
tar -czf logs-backup-$(date +%Y%m%d-%H%M%S).tar.gz logs/
# Create container backup
docker commit monacousa-portal monacousa-portal:backup-$(date +%Y%m%d-%H%M%S)
```
## 🛠️ Troubleshooting
### Common Issues
1. **Container won't start**
```bash
# Check logs
docker-compose logs monacousa-portal
# Check environment variables
docker-compose config
```
2. **Health check failing**
```bash
# Test health endpoint manually
docker exec monacousa-portal curl http://localhost:3000/api/health
# Check service dependencies
# Verify Keycloak, NocoDB, and MinIO connectivity
```
3. **Build failures**
```bash
# Clear build cache
docker builder prune
# Rebuild without cache
docker-compose build --no-cache
```
4. **Permission issues**
```bash
# Fix volume permissions
sudo chown -R 1001:1001 data/
sudo chown -R 1001:1001 logs/
```
### Debug Commands
```bash
# Enter container shell
docker exec -it monacousa-portal sh
# View environment variables
docker exec monacousa-portal env
# Check file permissions
docker exec monacousa-portal ls -la /app/
# Test network connectivity
docker exec monacousa-portal ping auth.monacousa.org
```
## 📊 Performance Optimization
### Resource Limits
The Docker Compose configuration includes resource limits:
- **Memory**: 512MB limit, 256MB reservation
- **CPU**: Adjust based on your server capacity
### Optimization Tips
1. **Image optimization**
- Multi-stage builds reduce image size
- Alpine Linux base for smaller footprint
- Proper .dockerignore to exclude unnecessary files
2. **Runtime optimization**
- Health checks prevent traffic to unhealthy containers
- Proper signal handling for graceful shutdowns
- Non-root user for security
3. **Deployment optimization**
- Zero-downtime deployments
- Build caching for faster CI/CD
- Image cleanup to save disk space
## 🔐 Security Considerations
### Container Security
- **Non-root user**: Application runs as `nuxt` user (UID 1001)
- **Read-only filesystem**: Consider adding read-only root filesystem
- **Security scanning**: Regularly scan images for vulnerabilities
- **Secrets management**: Environment variables for sensitive data
### Network Security
- **Reverse proxy**: Use nginx for SSL termination
- **Firewall**: Restrict access to necessary ports only
- **SSL/TLS**: Always use HTTPS in production
- **Security headers**: Configure appropriate security headers
### Deployment Security
- **SSH keys**: Use key-based authentication for deployments
- **Limited permissions**: Deploy user has minimal required permissions
- **Registry security**: Use private registry with authentication
- **Environment isolation**: Separate staging and production environments
## 📚 Additional Resources
### Docker Commands Reference
```bash
# Build image locally
docker build -t monacousa-portal .
# Run container with environment file
docker run --env-file .env -p 3000:3000 monacousa-portal
# View container logs
docker logs -f monacousa-portal
# Execute commands in container
docker exec -it monacousa-portal sh
# Clean up unused images
docker image prune -f
# View image layers
docker history monacousa-portal
```
### Docker Compose Commands
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Restart specific service
docker-compose restart monacousa-portal
# Update and restart
docker-compose up -d --no-deps monacousa-portal
# Stop all services
docker-compose down
# Remove volumes (careful!)
docker-compose down -v
```
### Gitea Actions Tips
1. **Testing workflows locally**
- Use `act` tool to test workflows locally
- Validate YAML syntax before pushing
2. **Debugging workflows**
- Add debug steps with `echo` commands
- Use `actions/upload-artifact` for debugging files
3. **Optimizing build times**
- Use build caching
- Minimize context size with .dockerignore
- Use multi-stage builds effectively
## 🎯 Best Practices
### Development Workflow
1. **Local development**
- Use docker-compose for consistent environment
- Mount source code for live reloading
- Use separate .env.local for development
2. **Testing**
- Test Docker builds locally before pushing
- Verify health endpoints work correctly
- Test with production-like data volumes
3. **Deployment**
- Always test in staging first
- Monitor health checks after deployment
- Keep rollback procedures ready
### Production Checklist
- [ ] Environment variables configured
- [ ] SSL certificates installed
- [ ] Firewall rules configured
- [ ] Monitoring and alerting set up
- [ ] Backup procedures tested
- [ ] Rollback procedures tested
- [ ] Health checks working
- [ ] Log rotation configured
- [ ] Resource limits appropriate
- [ ] Security scanning completed
This deployment guide provides everything needed to successfully containerize and deploy the MonacoUSA Portal using Docker and Gitea Actions CI/CD pipeline.

View File

@ -1,77 +1,34 @@
# Monaco USA Portal - SvelteKit Application ARG NODE_VERSION=22.12.0
# Multi-stage build for optimized production image ARG PORT=6060
# ============================================ FROM node:${NODE_VERSION}-slim as base
# 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 ENV NODE_ENV=production
ENV NODE_OPTIONS=--max-old-space-size=8192
WORKDIR /app
# Create non-root user for security FROM base as build
RUN addgroup --system --gid 1001 nodejs COPY package.json .
RUN adduser --system --uid 1001 sveltekit COPY package-lock.json .
RUN npm install --production=false
COPY . .
RUN npm run build
RUN npm prune
# Copy built application FROM base as production
COPY --from=builder --chown=sveltekit:nodejs /app/build ./build ENV PORT=$PORT
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules COPY --from=build /app/.output /app/.output
COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json COPY --from=build /app/server/templates /app/server/templates
# Switch to non-root user # Copy debug entrypoint script
USER sveltekit COPY docker-entrypoint-debug.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-debug.sh
# Expose port # Add health check
EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:6060/api/health || exit 1
# Set runtime environment variables # Install curl and net-tools for health check and debugging
ENV HOST=0.0.0.0 RUN apt-get update && apt-get install -y curl net-tools wget && rm -rf /var/lib/apt/lists/*
ENV PORT=3000
# Start the application # Use debug entrypoint
CMD ["node", "build"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint-debug.sh"]

102
ENVIRONMENT_VARIABLES.md Normal file
View File

@ -0,0 +1,102 @@
# Environment Variables Configuration
## NocoDB Configuration (Required)
To fix API key issues and improve container deployment, set these environment variables in your Docker container:
### Required Variables
```bash
# NocoDB Database Connection
NUXT_NOCODB_URL=https://database.monacousa.org
NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
```
### Alternative Variable Names (also supported)
```bash
# Alternative formats that also work
NOCODB_URL=https://database.monacousa.org
NOCODB_TOKEN=your_actual_nocodb_api_token_here
NOCODB_API_TOKEN=your_actual_nocodb_api_token_here
NOCODB_BASE_ID=your_nocodb_base_id_here
```
## How to Set in Docker
### Option 1: Docker Compose (Recommended)
Add to your `docker-compose.yml`:
```yaml
services:
monacousa-portal:
image: your-image
environment:
- NUXT_NOCODB_URL=https://database.monacousa.org
- NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
- NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
# ... rest of your config
```
### Option 2: Docker Run Command
```bash
docker run -d \
-e NUXT_NOCODB_URL=https://database.monacousa.org \
-e NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here \
-e NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here \
your-image
```
### Option 3: Environment File
Create `.env` file:
```bash
NUXT_NOCODB_URL=https://database.monacousa.org
NUXT_NOCODB_TOKEN=your_actual_nocodb_api_token_here
NUXT_NOCODB_BASE_ID=your_nocodb_base_id_here
```
Then use:
```bash
docker run --env-file .env your-image
```
## Priority Order
The system will check configuration in this order:
1. **Environment Variables** (highest priority)
2. Admin Panel Configuration (fallback)
3. Runtime Config (last resort)
## Benefits
**Container-Friendly**: No need to configure through web UI
**Secure**: API tokens stored as environment variables
**Reliable**: No Unicode/formatting issues
**Version Control**: Can be managed in deployment configs
**Scalable**: Same config across multiple containers
## Getting Your Values
### NocoDB API Token
1. Go to your NocoDB instance
2. Click your profile → API Tokens
3. Create new token or copy existing one
4. Use the raw token without any formatting
### NocoDB Base ID
1. In NocoDB, go to your base
2. Check the URL: `https://your-nocodb.com/dashboard/#/nc/base/BASE_ID_HERE`
3. Copy the BASE_ID part
## Testing Configuration
After setting environment variables, check the logs:
- ✅ `[nocodb] ✅ Using environment variables - URL: https://database.monacousa.org`
- ✅ `[nocodb] ✅ Configuration validated successfully`
If you see fallback messages, the environment variables aren't being read correctly.

144
README.md
View File

@ -1,38 +1,134 @@
# sv # MonacoUSA Portal Foundation
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). This folder contains the complete foundation and implementation guide for creating the **MonacoUSA Portal** - a modern, responsive web portal built with the same proven tech stack as the Port Nimara client portal.
## Creating a project ## 📁 Contents
If you're seeing this, you've probably already done this step. Congrats! ### 1. `MONACOUSA_PORTAL_IMPLEMENTATION.md`
**Complete step-by-step implementation guide** containing:
- ✅ Full project setup instructions
- ✅ All code templates and configurations
- ✅ Keycloak authentication implementation
- ✅ NocoDB database integration
- ✅ MinIO file storage setup
- ✅ Responsive dashboard with Vuetify 3
- ✅ PWA configuration
- ✅ Production deployment guide
- ✅ Testing and verification steps
```sh ### 2. `CLINE_WORKSPACE_RULES.md`
# create a new project in the current directory **Cline workspace configuration file** containing:
npx sv create - 🔧 Project overview and tech stack details
- 🔧 Development guidelines and coding standards
- 🔧 Environment configuration requirements
- 🔧 Key features and capabilities
- 🔧 Extension guidelines for adding new tools
- 🔧 Troubleshooting and best practices
- 🔧 Support resources and documentation
# create a new project in my-app ### 3. `DOCKER_DEPLOYMENT_GUIDE.md`
npx sv create my-app **Complete Docker and CI/CD deployment guide** containing:
``` - 🐳 Multi-stage Docker build configuration
- 🔄 Gitea Actions CI/CD pipeline setup
- 📁 Volume management and persistent data
- 🔍 Health checks and monitoring
- 🛠️ Troubleshooting and best practices
- 🔐 Security considerations and optimization
## Developing ## 🚀 Quick Start
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 1. **Give the implementation guide to another Claude instance**:
- Copy the contents of `MONACOUSA_PORTAL_IMPLEMENTATION.md`
- Provide it to Claude with instructions to follow the guide step-by-step
```sh 2. **Set up Cline workspace rules**:
npm run dev - Copy the contents of `CLINE_WORKSPACE_RULES.md`
- Add it to your Cline workspace rules for the new project
# or start the server and open the app in a new browser tab ## 🎯 Project Specifications
npm run dev -- --open
```
## Building - **Name**: monacousa-portal
- **Domain**: monacousa.org (configurable)
- **Colors**: #a31515 (MonacoUSA red) primary, #ffffff (white) secondary
- **Tech Stack**: Nuxt 3, Vue 3, Vuetify 3, Keycloak, NocoDB, MinIO
- **Features**: PWA, Mobile-responsive, Dashboard layout, File storage
To create a production version of your app: ## 📋 What You'll Get
```sh Following this implementation guide will create a complete portal foundation with:
npm run build
```
You can preview the production build with `npm run preview`. ### ✅ Authentication System
- Keycloak OAuth2/OIDC integration
- Secure session management
- Role-based access control
- Login/logout functionality
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ### ✅ Responsive Dashboard
- Mobile-first design
- Collapsible sidebar navigation
- User profile display
- Role-based menu items
### ✅ File Management
- MinIO S3-compatible storage
- Upload/download functionality
- File type validation
- Secure access control
### ✅ Database Integration
- NocoDB API-first database
- CRUD operations
- Dynamic table access
- Type-safe operations
### ✅ PWA Features
- Offline support
- Install prompts
- Service worker
- Mobile optimization
### ✅ Production Ready
- Health check endpoints
- Error handling
- Security best practices
- Deployment configuration
## 🛠️ Usage Instructions
### For Implementation
1. Create a new repository for your MonacoUSA Portal
2. Follow the step-by-step guide in `MONACOUSA_PORTAL_IMPLEMENTATION.md`
3. Configure your environment variables
4. Set up Keycloak, NocoDB, and MinIO services
5. Test the implementation
6. Deploy to production
### For Development
1. Use `CLINE_WORKSPACE_RULES.md` as your Cline workspace rules
2. Follow the coding standards and guidelines
3. Extend the portal with your custom tools
4. Maintain consistency with the established patterns
## 🎨 Customization
The foundation is designed to be easily customizable:
- **Branding**: Update colors, logos, and text
- **Tools**: Add new dashboard pages and functionality
- **APIs**: Extend with custom server endpoints
- **Database**: Add new tables and data structures
- **UI**: Customize components and layouts
## 📞 Support
This foundation is based on the proven Port Nimara client portal architecture and includes:
- Comprehensive documentation
- Complete code examples
- Best practices and patterns
- Troubleshooting guides
- Extension guidelines
The implementation guide is self-contained and can be followed by any developer or AI assistant to create the exact foundation you need.
---
**Ready to build your MonacoUSA Portal!** 🚀

14
app.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
// Global app setup
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
},
});
</script>

View File

@ -0,0 +1,474 @@
// ============================================
// Dashboard Component Styles
// Professional enhancements for all dashboards
// ============================================
// Dashboard Container
.admin-dashboard,
.board-dashboard,
.member-dashboard {
padding: 2rem;
min-height: 100vh;
background-color: #fafafa; // Fallback for browsers that don't support gradients
background-image: linear-gradient(135deg, #fafafa 0%, #f4f4f5 100%);
background: linear-gradient(135deg, #fafafa 0%, #f4f4f5 100%);
@media (max-width: 768px) {
padding: 1rem;
}
}
// Enhanced Dashboard Header
.dashboard-header {
text-align: center;
padding: 3rem 2rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
&.glass-header {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 24px;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(
circle,
rgba(220, 38, 38, 0.03) 0%,
transparent 70%
);
animation: float 20s ease-in-out infinite;
}
}
.dashboard-title {
font-size: 3rem;
font-weight: 800;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
&.text-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@media (max-width: 768px) {
font-size: 2rem;
}
}
.dashboard-subtitle {
font-size: 1.125rem;
color: #64748b;
font-weight: 500;
}
}
// Enhanced Stat Cards
.stat-card {
height: 100%;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
margin: 0.5rem 0;
@media (max-width: 768px) {
font-size: 2rem;
}
}
&:hover {
transform: translateY(-6px) scale(1.02);
box-shadow:
0 25px 50px rgba(0, 0, 0, 0.15),
0 10px 30px rgba(220, 38, 38, 0.1);
}
.v-avatar {
background: linear-gradient(135deg,
rgba(var(--v-theme-on-surface), 0.05) 0%,
rgba(var(--v-theme-on-surface), 0.02) 100%);
}
}
// Enhanced Glass Cards
.glass-card {
background: rgba(255, 255, 255, 0.88) !important;
backdrop-filter: blur(16px) saturate(180%) !important;
-webkit-backdrop-filter: blur(16px) saturate(180%) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6) !important;
&:hover {
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8) !important;
}
}
// Enhanced Bento Grid
.bento-grid {
display: grid !important;
grid-template-columns: repeat(12, 1fr) !important;
gap: 1.5rem !important;
margin-bottom: 2rem;
.bento-item {
position: relative;
&--small {
grid-column: span 3 !important;
@media (max-width: 1280px) {
grid-column: span 6 !important;
}
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--medium {
grid-column: span 4 !important;
@media (max-width: 1280px) {
grid-column: span 6 !important;
}
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--large {
grid-column: span 6 !important;
@media (max-width: 768px) {
grid-column: span 12 !important;
}
}
&--xlarge {
grid-column: span 8 !important;
@media (max-width: 1280px) {
grid-column: span 12 !important;
}
}
&--full {
grid-column: span 12 !important;
}
}
}
// Enhanced Data Tables
.v-data-table {
background: transparent !important;
.v-data-table__wrapper {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 16px;
overflow: hidden;
}
thead {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.03) 0%,
rgba(185, 28, 28, 0.01) 100%);
th {
font-weight: 600 !important;
font-size: 0.75rem !important;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b !important;
padding: 1rem !important;
}
}
tbody {
tr {
transition: all 0.2s ease;
&:hover {
background: rgba(220, 38, 38, 0.02) !important;
td {
color: #1f2937 !important;
}
}
td {
padding: 1rem !important;
font-size: 0.875rem;
color: #475569;
}
}
}
}
// Enhanced Buttons in Dashboards
.dashboard-action-btn {
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(220, 38, 38, 0.2);
&::before {
width: 300px;
height: 300px;
}
}
}
// Activity Timeline Enhancement
.activity-timeline {
.v-timeline-item {
&::before {
background: linear-gradient(180deg,
rgba(220, 38, 38, 0.1) 0%,
transparent 100%);
}
.v-timeline-item__dot {
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.2);
}
}
}
// Quick Actions Enhancement
.quick-actions-card {
.v-btn {
margin: 0.25rem;
&:hover {
transform: translateY(-2px);
}
}
}
// Enhanced Loading States
.skeleton-loader {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.5) 25%,
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0.5) 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
}
33% {
transform: translate(30px, -30px) rotate(120deg);
}
66% {
transform: translate(-20px, 20px) rotate(240deg);
}
}
// Animated Entrance
.animated-entrance {
animation: slideInUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Professional Typography in Dashboards
.dashboard-section-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1.5rem;
position: relative;
padding-left: 1rem;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 24px;
background: linear-gradient(180deg, #dc2626 0%, #b91c1c 100%);
border-radius: 2px;
}
}
// Status Badges Enhancement
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--active {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.1) 0%,
rgba(34, 197, 94, 0.05) 100%);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
}
&--pending {
background: linear-gradient(135deg,
rgba(245, 158, 11, 0.1) 0%,
rgba(245, 158, 11, 0.05) 100%);
color: #ca8a04;
border: 1px solid rgba(245, 158, 11, 0.2);
}
&--inactive {
background: linear-gradient(135deg,
rgba(107, 114, 128, 0.1) 0%,
rgba(107, 114, 128, 0.05) 100%);
color: #6b7280;
border: 1px solid rgba(107, 114, 128, 0.2);
}
}
// Chart Card Enhancement
.chart-card {
.chart-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
.chart-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.chart-subtitle {
font-size: 0.875rem;
color: #64748b;
margin-top: 0.25rem;
}
}
.chart-body {
padding: 1.5rem;
position: relative;
}
}
// Responsive Improvements
@media (max-width: 768px) {
.dashboard-header {
padding: 2rem 1rem;
.dashboard-title {
font-size: 1.75rem;
}
.dashboard-subtitle {
font-size: 1rem;
}
}
.bento-grid {
gap: 1rem !important;
}
.stat-card {
.stat-value {
font-size: 1.75rem;
}
}
}
// Dark Mode Support
@media (prefers-color-scheme: dark) {
.admin-dashboard,
.board-dashboard,
.member-dashboard {
background: linear-gradient(135deg, #18181b 0%, #27272a 100%);
}
.dashboard-header.glass-header {
background: rgba(30, 30, 30, 0.9);
border-color: rgba(255, 255, 255, 0.1);
}
.glass-card {
background: rgba(30, 30, 30, 0.88) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.dashboard-title.text-gradient {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-value,
.dashboard-section-title {
color: #f4f4f5;
}
}

View File

@ -0,0 +1,652 @@
// Monaco USA Portal - Design System v2.0
// Addressing critical issues from visual audit
// ============================================
// 1. COLOR PALETTE - Standardized
// ============================================
// Brand Colors
$monaco-red: #DC143C;
$monaco-red-dark: #B91C3C;
$monaco-red-light: #FF6B8A;
$monaco-white: #FFFFFF;
$monaco-gold: #FFD700;
// Primary color variations (for dashboard-v2 compatibility)
$primary-600: #dc2626; // Same as refined Monaco red
$primary-700: #b91c1c; // Same as monaco-red-dark
$primary-800: #991b1b; // Darker shade
// Semantic Colors
$color-success: #10B981;
$color-warning: #F59E0B;
$color-error: #EF4444;
$color-info: #3B82F6;
// Semantic color variations (for dashboard-v2 compatibility)
$success-500: #10B981;
$warning-500: #F59E0B;
$error-500: #EF4444;
$info-500: #3B82F6;
$blue-500: #3B82F6; // Same as info color
$blue-600: #2563EB; // Slightly darker blue
// Neutral Palette
$neutral-900: #0F172A;
$neutral-800: #1E293B;
$neutral-700: #334155;
$neutral-600: #475569;
$neutral-500: #64748B;
$neutral-400: #94A3B8;
$neutral-300: #CBD5E1;
$neutral-200: #E2E8F0;
$neutral-100: #F1F5F9;
$neutral-50: #F8FAFC;
// Glass Morphism
$glass-white: rgba(255, 255, 255, 0.1);
$glass-white-hover: rgba(255, 255, 255, 0.15);
$glass-border: rgba(255, 255, 255, 0.2);
$glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
// ============================================
// 2. TYPOGRAPHY - Consistent Hierarchy
// ============================================
// Font Family
$font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
$font-mono: 'Fira Code', 'Monaco', monospace;
// Font Sizes - Using rem for accessibility
$text-xs: 0.75rem; // 12px
$text-sm: 0.875rem; // 14px
$text-base: 1rem; // 16px
$text-lg: 1.125rem; // 18px
$text-xl: 1.25rem; // 20px
$text-2xl: 1.5rem; // 24px
$text-3xl: 1.875rem; // 30px
$text-4xl: 2.25rem; // 36px
$text-4xl: 2.25rem; // 36px
$text-5xl: 3rem; // 48px
// Line Heights
$leading-none: 1;
$leading-tight: 1.2;
$leading-snug: 1.375;
$leading-normal: 1.6;
$leading-relaxed: 1.75;
$leading-loose: 2;
// Font Weights
$font-light: 300;
$font-regular: 400;
$font-medium: 500;
$font-semibold: 600;
$font-bold: 700;
$font-extrabold: 800;
// ============================================
// 3. SPACING SYSTEM - 8px Grid
// ============================================
$space-px: 1px;
$space-0: 0;
$space-1: 0.25rem; // 4px
$space-2: 0.5rem; // 8px
$space-3: 0.75rem; // 12px
$space-4: 1rem; // 16px
$space-5: 1.25rem; // 20px
$space-6: 1.5rem; // 24px
$space-7: 1.75rem; // 28px
$space-8: 2rem; // 32px
$space-10: 2.5rem; // 40px
$space-12: 3rem; // 48px
$space-16: 4rem; // 64px
$space-20: 5rem; // 80px
$space-24: 6rem; // 96px
// ============================================
// 4. BORDER RADIUS - Consistent Curves
// ============================================
$radius-none: 0;
$radius-sm: 0.25rem; // 4px
$radius-md: 0.5rem; // 8px
$radius-lg: 0.75rem; // 12px
$radius-xl: 1rem; // 16px
$radius-2xl: 1.5rem; // 24px
$radius-full: 9999px;
// ============================================
// 5. SHADOWS - Depth System
// ============================================
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
$shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
$shadow-2xl: 0 25px 50px rgba(0, 0, 0, 0.25);
$shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.06);
$shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.1);
// Additional shadows for dashboard-v2 compatibility
$shadow-inset-sm: inset 0 1px 3px rgba(0, 0, 0, 0.08);
$shadow-soft-md: 0 4px 12px rgba(0, 0, 0, 0.05);
// ============================================
// 6. BREAKPOINTS - Mobile First
// ============================================
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;
$breakpoint-2xl: 1536px;
@mixin sm {
@media (min-width: $breakpoint-sm) { @content; }
}
@mixin md {
@media (min-width: $breakpoint-md) { @content; }
}
@mixin lg {
@media (min-width: $breakpoint-lg) { @content; }
}
@mixin xl {
@media (min-width: $breakpoint-xl) { @content; }
}
@mixin xxl {
@media (min-width: $breakpoint-2xl) { @content; }
}
// ============================================
// 7. TRANSITIONS - Smooth Interactions
// ============================================
$ease-linear: linear;
$ease-in: cubic-bezier(0.4, 0, 1, 1);
$ease-out: cubic-bezier(0, 0, 0.2, 1);
$ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
$ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
// Additional easing for dashboard-v2 compatibility
$spring-smooth: cubic-bezier(0.34, 1.56, 0.64, 1);
$duration-fast: 150ms;
$duration-normal: 250ms;
$duration-slow: 350ms;
$duration-slower: 500ms;
// Common transition for dashboard-v2 compatibility
$transition-base: all $duration-normal $ease-out;
$transition-fast: all $duration-fast $ease-out;
// ============================================
// 8. Z-INDEX SCALE - Layering System
// ============================================
$z-negative: -1;
$z-0: 0;
$z-10: 10;
$z-20: 20;
$z-30: 30;
$z-40: 40;
$z-50: 50;
$z-dropdown: 1000;
$z-sticky: 1020;
$z-fixed: 1030;
$z-modal-backdrop: 1040;
$z-modal: 1050;
$z-popover: 1060;
$z-tooltip: 1070;
$z-notification: 1080;
// ============================================
// 9. IMPROVED GLASS EFFECT MIXIN
// ============================================
@mixin glass-effect(
$blur: 10px,
$opacity: 0.1,
$border-opacity: 0.2,
$shadow: true
) {
background: rgba(255, 255, 255, $opacity);
@supports (backdrop-filter: blur($blur)) or (-webkit-backdrop-filter: blur($blur)) {
backdrop-filter: blur($blur);
-webkit-backdrop-filter: blur($blur);
}
border: 1px solid rgba(255, 255, 255, $border-opacity);
@if $shadow {
box-shadow: $shadow-glass;
}
transition: all $duration-normal $ease-out;
&:hover {
background: rgba(255, 255, 255, $opacity + 0.05);
border-color: rgba(255, 255, 255, $border-opacity + 0.1);
@if $shadow {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
}
}
// ============================================
// 10. NEUMORPHIC & MORPHING MIXINS - Dashboard V2 Compatibility
// ============================================
@mixin neumorphic-card($size: 'md') {
$depth: 6px;
$blur: 12px;
@if $size == 'sm' {
$depth: 4px;
$blur: 8px;
} @else if $size == 'lg' {
$depth: 8px;
$blur: 16px;
}
background: linear-gradient(145deg, #ffffff, #f5f5f5);
box-shadow:
$depth $depth $blur rgba(0, 0, 0, 0.1),
(-$depth) (-$depth) $blur rgba(255, 255, 255, 0.7);
border-radius: $radius-xl;
border: 1px solid rgba(255, 255, 255, 0.5);
transition: all $duration-normal $ease-out;
&:hover {
transform: translateY(-2px);
box-shadow:
($depth + 2px) ($depth + 2px) ($blur + 4px) rgba(0, 0, 0, 0.15),
(-$depth - 2px) (-$depth - 2px) ($blur + 4px) rgba(255, 255, 255, 0.8);
}
}
@mixin morphing-dropdown() {
position: relative;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: $radius-lg;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.05),
inset -2px -2px 5px rgba(255, 255, 255, 0.9);
transition: all $duration-normal $ease-out;
&:focus-within {
box-shadow:
inset 3px 3px 8px rgba(0, 0, 0, 0.1),
inset -3px -3px 8px rgba(255, 255, 255, 0.95);
}
}
@mixin neumorphic-button() {
background: linear-gradient(145deg, #ffffff, #f5f5f5);
box-shadow:
4px 4px 8px rgba(0, 0, 0, 0.1),
-4px -4px 8px rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: $radius-lg;
transition: all $duration-fast $ease-out;
&:hover {
transform: translateY(-1px);
box-shadow:
6px 6px 12px rgba(0, 0, 0, 0.12),
-6px -6px 12px rgba(255, 255, 255, 0.8);
}
&:active {
transform: translateY(0);
box-shadow:
inset 2px 2px 5px rgba(0, 0, 0, 0.1),
inset -2px -2px 5px rgba(255, 255, 255, 0.9);
}
}
@mixin responsive($breakpoint) {
@media (min-width: $breakpoint) {
@content;
}
}
// ============================================
// 11. COMPONENT CLASSES - Reusable Styles
// ============================================
// Cards
.card-base {
@include glass-effect(12px, 0.08, 0.18, true);
border-radius: $radius-xl;
padding: $space-6;
margin-bottom: $space-4;
@include md {
padding: $space-8;
}
}
// Buttons
@mixin button-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $space-2;
padding: $space-3 $space-6;
border-radius: $radius-lg;
font-weight: $font-medium;
transition: all $duration-normal $ease-out;
cursor: pointer;
border: 1px solid transparent;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $monaco-red;
outline-offset: 2px;
}
}
.btn-primary {
@include button-base;
background: linear-gradient(135deg, $monaco-red 0%, $monaco-red-dark 100%);
color: white;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba($monaco-red, 0.3);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-secondary {
@include button-base;
background: $neutral-100;
color: $neutral-800;
border-color: $neutral-300;
&:hover:not(:disabled) {
background: $neutral-200;
border-color: $neutral-400;
}
}
.btn-ghost {
@include button-base;
background: transparent;
color: $neutral-600;
&:hover:not(:disabled) {
background: $neutral-100;
color: $neutral-800;
}
}
// Typography Classes
.heading-1 {
font-size: $text-4xl;
font-weight: $font-bold;
line-height: $leading-tight;
color: $neutral-900;
@include md {
font-size: $text-5xl;
}
}
.heading-2 {
font-size: $text-3xl;
font-weight: $font-semibold;
line-height: $leading-tight;
color: $neutral-900;
@include md {
font-size: $text-4xl;
}
}
.heading-3 {
font-size: $text-2xl;
font-weight: $font-semibold;
line-height: $leading-snug;
color: $neutral-800;
}
.heading-4 {
font-size: $text-xl;
font-weight: $font-medium;
line-height: $leading-snug;
color: $neutral-800;
}
.body-text {
font-size: $text-base;
line-height: $leading-normal;
color: $neutral-700;
}
.small-text {
font-size: $text-sm;
line-height: $leading-normal;
color: $neutral-600;
}
// ============================================
// 12. LAYOUT UTILITIES
// ============================================
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 $space-4;
@include md {
padding: 0 $space-6;
}
@include lg {
padding: 0 $space-8;
}
}
.grid {
display: grid;
gap: $space-4;
&.grid-cols-1 {
grid-template-columns: repeat(1, 1fr);
}
@include md {
&.md\:grid-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
}
@include lg {
&.lg\:grid-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
&.lg\:grid-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
}
.flex {
display: flex;
&.flex-col {
flex-direction: column;
}
&.items-center {
align-items: center;
}
&.justify-between {
justify-content: space-between;
}
&.gap-2 {
gap: $space-2;
}
&.gap-4 {
gap: $space-4;
}
}
// ============================================
// 13. ANIMATION CLASSES
// ============================================
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-fadeIn {
animation: fadeIn $duration-normal $ease-out;
}
.animate-slideIn {
animation: slideIn $duration-slow $ease-out;
}
.animate-pulse {
animation: pulse 2s $ease-in-out infinite;
}
// ============================================
// 14. ACCESSIBILITY UTILITIES
// ============================================
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.focus-visible {
&:focus-visible {
outline: 2px solid $monaco-red;
outline-offset: 2px;
border-radius: $radius-sm;
}
}
// ============================================
// 15. STATUS INDICATORS
// ============================================
.status-badge {
display: inline-flex;
align-items: center;
padding: $space-1 $space-3;
border-radius: $radius-full;
font-size: $text-xs;
font-weight: $font-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
&.status-overdue {
background: rgba($color-error, 0.1);
color: $color-error;
border: 1px solid rgba($color-error, 0.2);
}
&.status-pending {
background: rgba($color-warning, 0.1);
color: $color-warning;
border: 1px solid rgba($color-warning, 0.2);
}
&.status-paid {
background: rgba($color-success, 0.1);
color: $color-success;
border: 1px solid rgba($color-success, 0.2);
}
}
// ============================================
// 16. LOADING STATES
// ============================================
.skeleton {
background: linear-gradient(
90deg,
$neutral-200 25%,
$neutral-100 50%,
$neutral-200 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: $radius-md;
&.skeleton-text {
height: $space-4;
margin-bottom: $space-2;
}
&.skeleton-card {
height: 120px;
margin-bottom: $space-4;
}
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

1046
assets/scss/main.scss Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@ -1,17 +0,0 @@
{
"$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"
}

View File

@ -0,0 +1,559 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-account-plus</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Add New Member
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-form ref="formRef" v-model="formValid" @submit.prevent="handleSubmit">
<v-row>
<!-- Personal Information Section -->
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Personal Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['First Name']"
label="First Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('First Name')"
:error-messages="getFieldError('First Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Last Name']"
label="Last Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Last Name')"
:error-messages="getFieldError('Last Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.Email"
label="Email Address"
type="email"
variant="outlined"
:rules="[rules.required, rules.email]"
required
:error="hasFieldError('Email')"
:error-messages="getFieldError('Email')"
/>
</v-col>
<v-col cols="12" md="6">
<PhoneInputWrapper
v-model="form.Phone"
label="Phone Number"
placeholder="Enter phone number"
:error="hasFieldError('Phone')"
:error-message="getFieldError('Phone')"
@phone-data="handlePhoneData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Date of Birth']"
label="Date of Birth"
type="date"
variant="outlined"
:error="hasFieldError('Date of Birth')"
:error-messages="getFieldError('Date of Birth')"
/>
</v-col>
<v-col cols="12" md="6">
<MultipleNationalityInput
v-model="form.Nationality"
label="Nationality"
:error="hasFieldError('Nationality')"
:error-message="getFieldError('Nationality')"
:max-nationalities="3"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.Address"
label="Address"
variant="outlined"
rows="2"
:error="hasFieldError('Address')"
:error-messages="getFieldError('Address')"
/>
</v-col>
<!-- Membership Information Section -->
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Membership Information</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="form['Membership Status']"
:items="membershipStatusOptions"
label="Membership Status"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Membership Status')"
:error-messages="getFieldError('Membership Status')"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="form['Member Since']"
label="Member Since"
type="date"
variant="outlined"
:error="hasFieldError('Member Since')"
:error-messages="getFieldError('Member Since')"
/>
</v-col>
<v-col cols="12" md="4">
<v-switch
v-model="duesPaid"
label="Current Year Dues Paid"
color="success"
inset
:error="hasFieldError('Current Year Dues Paid')"
:error-messages="getFieldError('Current Year Dues Paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="duesPaid">
<v-text-field
v-model="form['Membership Date Paid']"
label="Payment Date"
type="date"
variant="outlined"
:error="hasFieldError('Membership Date Paid')"
:error-messages="getFieldError('Membership Date Paid')"
hint="Enter the actual date when dues were paid (can be historical)"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6" v-if="!duesPaid">
<v-text-field
v-model="form['Payment Due Date']"
label="Payment Due Date"
type="date"
variant="outlined"
:error="hasFieldError('Payment Due Date')"
:error-messages="getFieldError('Payment Due Date')"
hint="Enter when payment is due (for new members in grace period)"
persistent-hint
/>
</v-col>
<!-- Dues Status Preview -->
<v-col cols="12" v-if="duesPaid && form['Membership Date Paid']">
<v-card variant="tonal" :color="calculatedDuesStatus.color" class="pa-3">
<div class="d-flex align-center">
<v-icon :color="calculatedDuesStatus.color" class="mr-2">
{{ calculatedDuesStatus.icon }}
</v-icon>
<div>
<div class="text-subtitle-2 font-weight-bold">
Calculated Dues Status: {{ calculatedDuesStatus.text }}
</div>
<div class="text-caption">
{{ calculatedDuesStatus.message }}
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-account-plus</v-icon>
Add Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { formatBooleanAsString } from '~/utils/client-utils';
import { isPaymentOverOneYear, isDuesActuallyCurrent, calculateOverdueDays } from '~/utils/dues-calculations';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'member-created', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
// Form data
const form = ref({
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0], // Today's date
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
});
// Additional form state
const duesPaid = ref(false);
const phoneData = ref(null);
// Error handling
const fieldErrors = ref<Record<string, string>>({});
// Computed dues status calculation
const calculatedDuesStatus = computed(() => {
if (!duesPaid.value || !form.value['Membership Date Paid']) {
return {
color: 'grey',
icon: 'mdi-help',
text: 'Unknown',
message: 'Please enter payment date to calculate status'
};
}
// Create a mock member object with form data to use calculation functions
const mockMember = {
current_year_dues_paid: 'true',
membership_date_paid: form.value['Membership Date Paid'],
payment_due_date: form.value['Payment Due Date'],
member_since: form.value['Member Since']
} as Member;
const isOverdue = !isDuesActuallyCurrent(mockMember);
const paymentTooOld = isPaymentOverOneYear(mockMember);
if (isOverdue && paymentTooOld) {
const overdueDays = calculateOverdueDays(mockMember);
return {
color: 'error',
icon: 'mdi-alert-circle',
text: 'Overdue',
message: `Payment is ${overdueDays} days overdue (more than 1 year since payment)`
};
} else if (isOverdue) {
return {
color: 'warning',
icon: 'mdi-clock-alert',
text: 'Due Soon',
message: 'Dues will be due soon based on payment date'
};
} else {
const paymentDate = new Date(form.value['Membership Date Paid']);
const nextDue = new Date(paymentDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
const nextDueFormatted = nextDue.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
color: 'success',
icon: 'mdi-check-circle',
text: 'Current',
message: `Dues are current. Next payment due: ${nextDueFormatted}`
};
}
});
// Watch dues paid switch
watch(duesPaid, (newValue) => {
form.value['Current Year Dues Paid'] = formatBooleanAsString(newValue);
if (newValue) {
form.value['Payment Due Date'] = '';
} else {
form.value['Membership Date Paid'] = '';
}
});
// Membership status options
const membershipStatusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
// Validation rules
const rules = {
required: (value: any) => {
if (typeof value === 'string') {
return !!value?.trim() || 'This field is required';
}
return !!value || 'This field is required';
},
email: (value: string) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || 'Please enter a valid email address';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const clearFieldErrors = () => {
fieldErrors.value = {};
};
// Phone data handler
const handlePhoneData = (data: any) => {
phoneData.value = data;
};
// Form submission
const handleSubmit = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
// Debug: Log the current form state
console.log('[AddMemberDialog] Form validation passed');
console.log('[AddMemberDialog] Current form.value:', JSON.stringify(form.value, null, 2));
console.log('[AddMemberDialog] Form keys:', Object.keys(form.value));
console.log('[AddMemberDialog] duesPaid switch value:', duesPaid.value);
// Get current form values
const currentForm = unref(form);
console.log('[AddMemberDialog] Unref form access test:');
console.log(' - First Name:', currentForm['First Name']);
console.log(' - Last Name:', currentForm['Last Name']);
console.log(' - Email:', currentForm.Email);
console.log(' - Phone:', currentForm.Phone);
// Simple approach - send the form data as-is with display names
// Let the server handle field normalization
const memberData = {
'First Name': currentForm['First Name']?.trim(),
'Last Name': currentForm['Last Name']?.trim(),
'Email': currentForm.Email?.trim(),
'Phone': currentForm.Phone?.trim() || '',
'Date of Birth': currentForm['Date of Birth'] || '',
'Nationality': currentForm.Nationality?.trim() || '',
'Address': currentForm.Address?.trim() || '',
'Membership Status': currentForm['Membership Status'],
'Member Since': currentForm['Member Since'] || '',
'Current Year Dues Paid': currentForm['Current Year Dues Paid'],
'Membership Date Paid': currentForm['Membership Date Paid'] || '',
'Payment Due Date': currentForm['Payment Due Date'] || ''
};
// Ensure required fields are not empty
if (!memberData['First Name']) {
console.error('[AddMemberDialog] First Name is empty. Raw value:', currentForm['First Name']);
throw new Error('First Name is required');
}
if (!memberData['Last Name']) {
console.error('[AddMemberDialog] Last Name is empty. Raw value:', currentForm['Last Name']);
throw new Error('Last Name is required');
}
if (!memberData['Email']) {
console.error('[AddMemberDialog] Email is empty. Raw value:', currentForm.Email);
throw new Error('Email is required');
}
console.log('[AddMemberDialog] Final memberData:', JSON.stringify(memberData, null, 2));
console.log('[AddMemberDialog] About to submit to API...');
const response = await $fetch<{ success: boolean; data: Member; message?: string }>('/api/members', {
method: 'POST',
body: memberData
});
if (response.success && response.data) {
console.log('[AddMemberDialog] Member created successfully:', response.data);
emit('member-created', response.data);
closeDialog();
resetForm();
} else {
throw new Error(response.message || 'Failed to create member');
}
} catch (error: any) {
console.error('[AddMemberDialog] Error creating member:', error);
// Handle validation errors
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
// Show general error
fieldErrors.value = {
general: error.message || 'Failed to create member. Please try again.'
};
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForm = () => {
form.value = {
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0],
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
};
duesPaid.value = false;
phoneData.value = null;
clearFieldErrors();
// Reset form validation
nextTick(() => {
formRef.value?.resetValidation();
});
};
// Watch for dialog open/close
watch(() => props.modelValue, (newValue) => {
if (newValue) {
// Dialog opened - reset form
resetForm();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section spacing */
.v-card-text .v-row .v-col:first-child h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Error message styling */
.field-error {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Switch styling */
.v-switch {
flex: none;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.v-dialog {
margin: 16px;
}
}
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
<template>
<v-card elevation="4" class="dues-management-card" style="border: 2px solid #dc2626; border-radius: 16px;">
<v-card-title class="pa-4 bg-warning-lighten-5">
<v-icon class="mr-3" color="warning" size="28">mdi-cash-multiple</v-icon>
<span class="text-h6 font-weight-bold">Dues Management</span>
<v-spacer />
<v-chip color="warning" size="small">
{{ overdueMembers.length + upcomingMembers.length }} Action Items
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="overdue">
<v-icon start>mdi-alert-circle</v-icon>
Overdue ({{ overdueMembers.length }})
</v-tab>
<v-tab value="upcoming">
<v-icon start>mdi-clock-alert</v-icon>
Due Soon ({{ upcomingMembers.length }})
</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab">
<!-- Overdue Dues Tab -->
<v-tabs-window-item value="overdue">
<div v-if="overdueMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="success" class="mb-2">mdi-check-circle</v-icon>
<p class="text-h6 text-success">All caught up!</p>
<p class="text-body-2">No members have overdue dues.</p>
</div>
<v-row v-else>
<v-col
v-for="member in overdueMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="overdue"
@mark-paid="handleMarkPaid"
@view-member="handleViewMember"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
<!-- Upcoming Dues Tab -->
<v-tabs-window-item value="upcoming">
<div v-if="upcomingMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="info" class="mb-2">mdi-calendar-check</v-icon>
<p class="text-h6 text-info">All up to date!</p>
<p class="text-body-2">No upcoming dues in the next 30 days.</p>
</div>
<v-row v-else>
<v-col
v-for="member in upcomingMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="upcoming"
@mark-paid="handleMarkPaid"
@view-member="handleViewMember"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
<!-- Refresh Button -->
<v-card-actions class="pa-4">
<v-btn
color="primary"
variant="outlined"
:loading="refreshLoading"
@click="refreshData"
>
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="$emit('view-all-members')"
>
<v-icon start>mdi-account-group</v-icon>
View All Members
</v-btn>
</v-card-actions>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
refreshTrigger?: number;
}
interface Emits {
(e: 'view-member', member: Member): void;
(e: 'view-all-members'): void;
(e: 'member-updated', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// State
const activeTab = ref('overdue');
const overdueMembers = ref<Member[]>([]);
const upcomingMembers = ref<Member[]>([]);
const loading = ref<Record<string, boolean>>({});
const refreshLoading = ref(false);
// View member dialog state
const showViewDialog = ref(false);
const selectedMember = ref<Member | null>(null);
// Load dues data
const loadDuesData = async () => {
refreshLoading.value = true;
try {
const response = await $fetch<{
success: boolean;
data: {
overdue: Member[];
upcoming: Member[];
};
}>('/api/members/dues-status');
if (response.success) {
// Sort members alphabetically by last name, then first name
const sortByName = (a: Member, b: Member) => {
const aLastName = (a.last_name || '').toLowerCase();
const bLastName = (b.last_name || '').toLowerCase();
const aFirstName = (a.first_name || '').toLowerCase();
const bFirstName = (b.first_name || '').toLowerCase();
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
return aFirstName.localeCompare(bFirstName);
};
overdueMembers.value = (response.data.overdue || []).sort(sortByName);
upcomingMembers.value = (response.data.upcoming || []).sort(sortByName);
}
} catch (error) {
console.error('Error loading dues data:', error);
// Show error notification
} finally {
refreshLoading.value = false;
}
};
// Handle mark as paid - let DuesActionCard handle the date picker and API call
const handleMarkPaid = async (member: Member) => {
// Remove member from current lists since they've been marked as paid
overdueMembers.value = overdueMembers.value.filter(m => m.Id !== member.Id);
upcomingMembers.value = upcomingMembers.value.filter(m => m.Id !== member.Id);
// Emit update event
emit('member-updated', member);
// Show success message
console.log('Dues marked as paid successfully');
};
// Handle view member
const handleViewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
// Handle edit member (from the view dialog)
const handleEditMember = (member: Member) => {
// Close the view dialog first
showViewDialog.value = false;
// Emit the view-member event which should trigger the edit dialog in the parent component
emit('view-member', member);
};
// Refresh data
const refreshData = () => {
loadDuesData();
};
// Watch for refresh trigger
watch(() => props.refreshTrigger, () => {
if (props.refreshTrigger) {
loadDuesData();
}
});
// Load data on mount
onMounted(() => {
loadDuesData();
});
</script>
<style scoped>
.dues-management-card {
border-radius: 12px !important;
}
.bg-warning-lighten-5 {
background-color: rgb(var(--v-theme-warning-lighten-5)) !important;
}
.v-tab {
text-transform: none !important;
}
.v-card-title {
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
}
</style>

116
components/CountryFlag.vue Normal file
View File

@ -0,0 +1,116 @@
<template>
<span class="country-flag" :class="{ 'country-flag--small': size === 'small' }">
<ClientOnly>
<VueCountryFlag
v-if="actualCountryCode"
:country="actualCountryCode"
:size="flagSize"
:title="getCountryName(actualCountryCode)"
/>
<template #fallback>
<span class="flag-placeholder" :style="placeholderStyle">🏳</span>
</template>
</ClientOnly>
<span v-if="showName && actualCountryCode" class="country-name">
{{ getCountryName(actualCountryCode) }}
</span>
</span>
</template>
<script setup lang="ts">
import VueCountryFlag from 'vue-country-flag-next';
import { getCountryName, parseCountryInput } from '~/utils/countries';
interface Props {
countryCode?: string;
showName?: boolean;
size?: 'small' | 'medium' | 'large';
square?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
countryCode: '',
showName: true,
size: 'medium',
square: false
});
// Convert country name to country code if needed
const actualCountryCode = computed(() => {
if (!props.countryCode) return '';
// If it's already a 2-letter code, use it
if (props.countryCode.length === 2) {
return props.countryCode.toUpperCase();
}
// Try to parse country name to get the code
const parsed = parseCountryInput(props.countryCode);
return parsed || '';
});
const flagSize = computed(() => {
const sizeMap = {
small: 'sm',
medium: 'md',
large: 'lg'
};
return sizeMap[props.size];
});
const placeholderStyle = computed(() => {
const sizeMap = {
small: '1rem',
medium: '1.5rem',
large: '2rem'
};
return {
width: sizeMap[props.size],
height: props.square ? sizeMap[props.size] : `calc(${sizeMap[props.size]} * 0.75)`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
backgroundColor: '#f5f5f5',
fontSize: '0.75rem'
};
});
</script>
<style scoped>
.country-flag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
vertical-align: middle;
}
.country-flag--small {
gap: 0.25rem;
}
.country-name {
font-size: 0.875rem;
color: inherit;
white-space: nowrap;
}
.country-flag--small .country-name {
font-size: 0.75rem;
}
.flag-placeholder {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Ensure proper flag display */
:deep(.vue-country-flag) {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,778 @@
<template>
<v-dialog v-model="show" max-width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-calendar-plus</v-icon>
<span>Create New Event</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="valid" @submit.prevent="handleSubmit">
<v-row>
<!-- Basic Information -->
<v-col cols="12">
<v-text-field
v-model="eventData.title"
label="Event Title*"
:rules="[v => !!v || 'Title is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<VuetifyTiptap
v-model="eventData.description"
label="Description"
:toolbar="[
'bold',
'italic',
'underline',
'|',
'heading',
'|',
'bulletList',
'orderedList',
'|',
'link',
'|',
'undo',
'redo'
]"
:max-height="200"
placeholder="Enter event description with formatting..."
outlined
/>
</v-col>
<!-- Event Type and Visibility -->
<v-col cols="12" md="6">
<v-select
v-model="eventData.event_type"
:items="eventTypes"
label="Event Type*"
:rules="[v => !!v || 'Event type is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.visibility"
:items="visibilityOptions"
label="Visibility*"
:rules="[v => !!v || 'Visibility is required']"
variant="outlined"
required
/>
</v-col>
<!-- Date and Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="startDate"
label="Start Date*"
type="date"
:rules="dateValidationRules.startDate"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:min="new Date().toISOString().split('T')[0]"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="startTime"
label="Start Time*"
type="time"
:rules="dateValidationRules.startTime"
variant="outlined"
prepend-inner-icon="mdi-clock"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="endDate"
label="End Date*"
type="date"
:rules="dateValidationRules.endDate"
variant="outlined"
prepend-inner-icon="mdi-calendar"
:min="startDate || new Date().toISOString().split('T')[0]"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="endTime"
label="End Time*"
type="time"
:rules="dateValidationRules.endTime"
variant="outlined"
prepend-inner-icon="mdi-clock"
required
/>
</v-col>
<!-- Location -->
<v-col cols="12">
<v-text-field
v-model="eventData.location"
label="Location"
variant="outlined"
/>
</v-col>
<!-- Capacity Settings -->
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.max_attendees"
label="Maximum Attendees"
type="number"
variant="outlined"
hint="Leave empty for unlimited capacity"
persistent-hint
/>
</v-col>
<!-- Guest Settings -->
<v-col cols="12" md="6">
<v-switch
v-model="allowGuests"
label="Allow Guests"
color="primary"
inset
hint="Members can bring additional guests"
persistent-hint
/>
</v-col>
<!-- Max Guests Per Person (shown when guests allowed) -->
<v-col v-if="allowGuests" cols="12" md="6">
<v-text-field
v-model="maxGuestsPerPerson"
label="Max Guests Per Person"
type="number"
variant="outlined"
:rules="allowGuests ? [v => v && parseInt(v) > 0 || 'Must allow at least 1 guest'] : []"
hint="Maximum additional guests each member can bring"
persistent-hint
/>
</v-col>
<!-- Payment Settings -->
<v-col cols="12" :md="allowGuests ? 6 : 6">
<v-switch
v-model="isPaidEvent"
label="Paid Event"
color="primary"
inset
/>
</v-col>
<!-- Payment Details (shown when paid event) -->
<template v-if="isPaidEvent">
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_members"
label="Cost for Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Member cost is required'] : []"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_non_members"
label="Cost for Non-Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Non-member cost is required'] : []"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="memberPricingEnabled"
label="Enable Member Pricing"
color="primary"
inset
hint="Allow current members to pay member rates"
persistent-hint
/>
</v-col>
</template>
<!-- Advanced Options -->
<v-col cols="12">
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Advanced Options
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-switch
v-model="isRecurring"
label="Recurring Event"
color="primary"
inset
hint="Create a series of events"
persistent-hint
/>
</v-col>
<v-col v-if="isRecurring" cols="12" md="6">
<v-select
v-model="recurrenceFrequency"
:items="recurrenceOptions"
label="Frequency"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.status"
:items="statusOptions"
label="Status"
variant="outlined"
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-form>
</v-card-text>
<!-- Error message display -->
<v-card-text v-if="errorMessage" class="pt-0">
<v-alert
type="error"
variant="tonal"
closable
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="close"
variant="outlined"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
@click="handleSubmit"
color="primary"
:loading="loading"
:disabled="!valid"
>
Create Event
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { EventCreateRequest } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
import { useEvents } from '~/composables/useEvents';
interface Props {
modelValue: boolean;
prefilledDate?: string;
prefilledEndDate?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
prefilledDate: undefined,
prefilledEndDate: undefined
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'event-created': [event: any];
}>();
const { isAdmin } = useAuth();
const { createEvent } = useEvents();
// Reactive state
const form = ref();
const valid = ref(false);
const loading = ref(false);
const isPaidEvent = ref(false);
const memberPricingEnabled = ref(true);
const isRecurring = ref(false);
const recurrenceFrequency = ref('weekly');
// Date and time picker state
const startDate = ref<string>('');
const startTime = ref<string>('');
const endDate = ref<string>('');
const endTime = ref<string>('');
// Form data
const eventData = reactive<EventCreateRequest>({
title: '',
description: '',
event_type: 'social',
start_datetime: '',
end_datetime: '',
location: '',
max_attendees: '',
is_paid: 'false',
cost_members: '',
cost_non_members: '',
member_pricing_enabled: 'true',
visibility: 'public',
status: 'active',
guests_permitted: 'false',
max_guests_permitted: '0'
});
// Guest settings
const allowGuests = ref(false);
const maxGuestsPerPerson = ref(1);
// Computed
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Options
const eventTypes = [
{ title: 'Social Event', value: 'social' },
{ title: 'Meeting', value: 'meeting' },
{ title: 'Fundraiser', value: 'fundraiser' },
{ title: 'Workshop', value: 'workshop' },
{ title: 'Board Only', value: 'board-only' }
];
const visibilityOptions = computed(() => {
const options = [
{ title: 'Public', value: 'public' },
{ title: 'Board Only', value: 'board-only' }
];
if (isAdmin.value) {
options.push({ title: 'Admin Only', value: 'admin-only' });
}
return options;
});
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Draft', value: 'draft' }
];
const recurrenceOptions = [
{ title: 'Weekly', value: 'weekly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Yearly', value: 'yearly' }
];
// Watchers
watch(isPaidEvent, (newValue) => {
eventData.is_paid = newValue ? 'true' : 'false';
});
watch(memberPricingEnabled, (newValue) => {
eventData.member_pricing_enabled = newValue ? 'true' : 'false';
});
watch(allowGuests, (newValue) => {
eventData.guests_permitted = newValue ? 'true' : 'false';
if (!newValue) {
eventData.max_guests_permitted = '0';
maxGuestsPerPerson.value = 1;
}
});
watch(maxGuestsPerPerson, (newValue) => {
if (allowGuests.value) {
eventData.max_guests_permitted = newValue.toString();
}
});
watch(isRecurring, (newValue) => {
eventData.is_recurring = newValue ? 'true' : 'false';
if (newValue) {
eventData.recurrence_pattern = JSON.stringify({
frequency: recurrenceFrequency.value,
interval: 1,
end_date: null
});
} else {
eventData.recurrence_pattern = '';
}
});
watch(recurrenceFrequency, (newValue) => {
if (isRecurring.value) {
eventData.recurrence_pattern = JSON.stringify({
frequency: newValue,
interval: 1,
end_date: null
});
}
});
// Auto-fill end date when start date is selected (most events are same day)
watch(startDate, (newStartDate) => {
if (newStartDate && !endDate.value) {
// Auto-fill end date to same as start date for same-day events
endDate.value = newStartDate;
console.log('[CreateEventDialog] Auto-filled end date to match start date:', newStartDate);
}
});
// Consolidated watcher for all date/time changes
watch([startDate, startTime, endDate, endTime], ([newStartDate, newStartTime, newEndDate, newEndTime]) => {
// Update start datetime
if (newStartDate && newStartTime) {
const startDateTime = createDateTime(newStartDate, newStartTime);
if (startDateTime) {
eventData.start_datetime = startDateTime.toISOString();
console.log('[CreateEventDialog] Updated start datetime:', eventData.start_datetime);
}
}
// Update end datetime
if (newEndDate && newEndTime) {
const endDateTime = createDateTime(newEndDate, newEndTime);
if (endDateTime) {
eventData.end_datetime = endDateTime.toISOString();
console.log('[CreateEventDialog] Updated end datetime:', eventData.end_datetime);
}
}
}, { deep: true });
// Watch for prefilled dates
watch(() => props.prefilledDate, (newDate) => {
if (newDate) {
const prefillDate = new Date(newDate);
startDate.value = prefillDate.toISOString().split('T')[0];
startTime.value = prefillDate.toTimeString().substring(0, 5);
// Set end date 2 hours later if not provided
if (!props.prefilledEndDate) {
const endDateTime = new Date(prefillDate);
endDateTime.setHours(endDateTime.getHours() + 2);
endDate.value = endDateTime.toISOString().split('T')[0];
endTime.value = endDateTime.toTimeString().substring(0, 5);
}
}
}, { immediate: true });
watch(() => props.prefilledEndDate, (newEndDate) => {
if (newEndDate) {
const prefillEndDate = new Date(newEndDate);
endDate.value = prefillEndDate.toISOString().split('T')[0];
endTime.value = prefillEndDate.toTimeString().substring(0, 5);
}
}, { immediate: true });
// Simple date/time functions
const createDateTime = (dateStr: string, timeStr: string): Date | null => {
if (!dateStr || !timeStr) return null;
const combined = new Date(`${dateStr}T${timeStr}:00`);
return isNaN(combined.getTime()) ? null : combined;
};
const isValidDateTime = (date: Date | null): boolean => {
return date !== null && !isNaN(date.getTime());
};
// Simple end time validation
const validateEndTime = (endTimeValue: string): boolean => {
if (!startDate.value || !endDate.value || !startTime.value || !endTimeValue) return true;
if (startDate.value !== endDate.value) return true;
const startDateTime = createDateTime(startDate.value, startTime.value);
const endDateTime = createDateTime(endDate.value, endTimeValue);
if (!startDateTime || !endDateTime) return false;
return endDateTime > startDateTime;
};
// Validation rules
const dateValidationRules = {
startDate: [
(v: string) => !!v || 'Start date is required',
(v: string) => !v || new Date(v).getTime() >= new Date().setHours(0,0,0,0) || 'Start date cannot be in the past'
],
startTime: [
(v: string) => !!v || 'Start time is required'
],
endDate: [
(v: string) => !!v || 'End date is required',
(v: string) => !v || !startDate.value || new Date(v).getTime() >= new Date(startDate.value).getTime() || 'End date must be same or after start date'
],
endTime: [
(v: string) => !!v || 'End time is required',
(v: string) => validateEndTime(v) || 'End time must be after start time when on same date'
]
};
// Methods
const resetForm = () => {
eventData.title = '';
eventData.description = '';
eventData.event_type = 'social';
eventData.start_datetime = '';
eventData.end_datetime = '';
eventData.location = '';
eventData.max_attendees = '';
eventData.is_paid = 'false';
eventData.cost_members = '';
eventData.cost_non_members = '';
eventData.member_pricing_enabled = 'true';
eventData.guests_permitted = 'false';
eventData.max_guests_permitted = '0';
eventData.visibility = 'public';
eventData.status = 'active';
eventData.is_recurring = 'false';
eventData.recurrence_pattern = '';
// Reset date/time fields
startDate.value = '';
startTime.value = '';
endDate.value = '';
endTime.value = '';
// Reset UI state
isPaidEvent.value = false;
memberPricingEnabled.value = true;
isRecurring.value = false;
recurrenceFrequency.value = 'weekly';
allowGuests.value = false;
maxGuestsPerPerson.value = 1;
form.value?.resetValidation();
};
const close = () => {
show.value = false;
resetForm();
};
// Error handling
const errorMessage = ref<string | null>(null);
const handleSubmit = async () => {
if (!form.value) return;
const isValid = await form.value.validate();
if (!isValid.valid) return;
// Clear previous errors
errorMessage.value = null;
// Validate that we have proper date/time combination
if (!startDate.value || !startTime.value) {
errorMessage.value = 'Start date and time are required';
return;
}
if (!endDate.value || !endTime.value) {
errorMessage.value = 'End date and time are required';
return;
}
loading.value = true;
try {
// Simple date validation using our new function
const startDateTime = createDateTime(startDate.value, startTime.value);
const endDateTime = createDateTime(endDate.value, endTime.value);
if (!startDateTime) {
errorMessage.value = 'Please enter a valid start date and time';
loading.value = false;
return;
}
if (!endDateTime) {
errorMessage.value = 'Please enter a valid end date and time';
loading.value = false;
return;
}
// Validate start is not in the past
if (startDateTime < new Date()) {
errorMessage.value = 'Event start time cannot be in the past';
loading.value = false;
return;
}
// Validate end is after start (using getTime() for precise comparison)
if (endDateTime.getTime() <= startDateTime.getTime()) {
errorMessage.value = 'Event end time must be after start time';
loading.value = false;
return;
}
const formattedEventData = {
...eventData,
start_datetime: startDateTime.toISOString(),
end_datetime: endDateTime.toISOString()
};
console.log('[CreateEventDialog] Creating event with data:', formattedEventData);
const newEvent = await createEvent(formattedEventData);
emit('event-created', newEvent);
close();
} catch (error: any) {
console.error('Error creating event:', error);
// Parse error message for better UX
let userErrorMessage = 'Failed to create event';
if (error?.data?.message) {
userErrorMessage = error.data.message;
} else if (error?.message) {
if (error.message.includes('past')) {
userErrorMessage = 'Event date cannot be in the past';
} else if (error.message.includes('validation')) {
userErrorMessage = 'Please check all required fields';
} else {
userErrorMessage = error.message;
}
}
errorMessage.value = userErrorMessage;
} finally {
loading.value = false;
}
};
// Removed duplicate prefilled date logic - handled by watchers above
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.v-expansion-panel-title {
font-weight: 500;
}
.v-switch {
flex: 0 0 auto;
}
.v-text-field :deep(.v-field__input) {
min-height: 56px;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
</style>

View File

@ -0,0 +1,313 @@
<template>
<v-dialog
v-model="isOpen"
max-width="500"
persistent
@keydown.esc="cancel"
>
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-account-plus</v-icon>
<span class="text-h6">Create Portal Account</span>
</v-card-title>
<v-card-text class="pb-2">
<div class="mb-4">
<p class="text-body-1 mb-2">
Create a portal account for <strong>{{ member?.FullName }}</strong>
</p>
<p class="text-body-2 text-medium-emphasis">
{{ member?.email }}
</p>
</div>
<div class="mb-4">
<v-alert
type="info"
variant="tonal"
class="mb-4"
icon="mdi-information"
>
<template #text>
<div class="text-body-2">
The user will receive an email to set up their password and complete registration.
</div>
</template>
</v-alert>
</div>
<v-form ref="formRef" v-model="formValid">
<v-select
v-model="selectedGroup"
:items="groupOptions"
label="Assign to Group"
variant="outlined"
density="comfortable"
:rules="groupRules"
prepend-inner-icon="mdi-account-group"
class="mb-3"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<v-icon :color="item.raw.color">{{ item.raw.icon }}</v-icon>
</template>
<v-list-item-title>{{ item.raw.title }}</v-list-item-title>
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
</v-list-item>
</template>
<template #selection="{ item }">
<div class="d-flex align-center">
<v-icon :color="item.raw.color" class="mr-2">{{ item.raw.icon }}</v-icon>
{{ item.raw.title }}
</div>
</template>
</v-select>
<!-- Group Description -->
<v-card
v-if="selectedGroup"
variant="tonal"
color="primary"
class="mb-3"
>
<v-card-text class="py-3">
<div class="d-flex align-center mb-2">
<v-icon :color="selectedGroupInfo?.color" class="mr-2">
{{ selectedGroupInfo?.icon }}
</v-icon>
<span class="font-weight-medium">{{ selectedGroupInfo?.title }}</span>
</div>
<p class="text-body-2 mb-2">{{ selectedGroupInfo?.description }}</p>
<div class="text-caption">
<strong>Permissions:</strong>
<ul class="mt-1 ml-4">
<li v-for="permission in selectedGroupInfo?.permissions" :key="permission">
{{ permission }}
</li>
</ul>
</div>
</v-card-text>
</v-card>
<!-- Error Alert -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-3"
closable
@click:close="errorMessage = ''"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-6">
<v-spacer />
<v-btn
variant="text"
@click="cancel"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="createAccount"
:loading="loading"
:disabled="!formValid || loading"
>
<v-icon start>mdi-account-plus</v-icon>
Create Account
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'account-created', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Reactive state
const loading = ref(false);
const errorMessage = ref('');
const formValid = ref(false);
const selectedGroup = ref('user');
const formRef = ref();
// Computed
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Group options with detailed information
const groupOptions = [
{
title: 'User',
value: 'user',
description: 'Standard member access',
icon: 'mdi-account',
color: 'primary',
permissions: [
'View own profile and update personal information',
'View events and RSVP',
'Access member directory (if enabled)',
'View dues status and payment history'
]
},
{
title: 'Board Member',
value: 'board',
description: 'Board member privileges',
icon: 'mdi-account-tie',
color: 'warning',
permissions: [
'All user permissions',
'Create and manage members',
'Create and manage events',
'View member statistics',
'Access board tools and reports'
]
},
{
title: 'Administrator',
value: 'admin',
description: 'Full system access',
icon: 'mdi-shield-crown',
color: 'error',
permissions: [
'All board member permissions',
'System configuration and settings',
'User and group management',
'Delete members and sensitive operations',
'Access admin panel and logs'
]
}
];
const selectedGroupInfo = computed(() => {
return groupOptions.find(group => group.value === selectedGroup.value);
});
// Validation rules
const groupRules = [
(v: string) => !!v || 'Please select a group'
];
// Methods
const cancel = () => {
errorMessage.value = '';
selectedGroup.value = 'user';
isOpen.value = false;
};
const createAccount = async () => {
if (!formValid.value || !props.member) return;
try {
loading.value = true;
errorMessage.value = '';
console.log('[CreatePortalAccountDialog] Creating portal account for:', props.member.email, 'Group:', selectedGroup.value);
const response = await $fetch(`/api/members/${props.member.Id}/create-portal-account`, {
method: 'POST',
body: {
membershipTier: selectedGroup.value
}
});
if (response?.success) {
console.log('[CreatePortalAccountDialog] Portal account created successfully');
// Update the member object with the keycloak_id
const updatedMember = {
...props.member,
keycloak_id: response.data?.keycloak_id
};
emit('account-created', updatedMember);
isOpen.value = false;
// Reset form
selectedGroup.value = 'user';
} else {
throw new Error(response?.message || 'Failed to create portal account');
}
} catch (err: any) {
console.error('[CreatePortalAccountDialog] Error creating portal account:', err);
// Better error handling
let message = 'Failed to create portal account. Please try again.';
if (err.statusCode === 409) {
message = 'This member already has a portal account or a user with this email already exists.';
} else if (err.statusCode === 400) {
message = 'Member must have email, first name, and last name to create a portal account.';
} else if (err.data?.message) {
message = err.data.message;
} else if (err.message) {
message = err.message;
}
errorMessage.value = message;
} finally {
loading.value = false;
}
};
// Reset form when dialog opens
watch(() => props.modelValue, (newValue) => {
if (newValue) {
selectedGroup.value = 'user';
errorMessage.value = '';
}
});
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
}
.v-list-item-subtitle {
opacity: 0.7;
}
/* Improve list styling */
ul {
list-style-type: disc;
margin-left: 0;
}
li {
margin-bottom: 2px;
}
/* Better mobile spacing */
@media (max-width: 600px) {
.v-card-actions {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
}
</style>

View File

@ -0,0 +1,593 @@
<template>
<v-card
:class="[
'dues-action-card',
status === 'overdue' ? 'dues-action-card--overdue' : 'dues-action-card--upcoming'
]"
elevation="2"
>
<!-- Status Badge -->
<div class="status-badge">
<v-chip
:color="statusColor"
size="small"
variant="flat"
>
<v-icon start size="12">{{ statusIcon }}</v-icon>
{{ statusText }}
</v-chip>
</div>
<v-card-text class="pa-4">
<!-- Member Info Header -->
<div class="d-flex align-center mb-3">
<ProfileAvatar
:member-id="member.member_id || member.Id"
:first-name="member.first_name"
:last-name="member.last_name"
:member-name="member.FullName"
size="small"
class="mr-3"
/>
<div class="flex-grow-1">
<h4 class="text-subtitle-1 font-weight-bold mb-1">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h4>
<div class="d-flex align-center">
<v-chip size="x-small" color="grey" variant="text" class="pa-0 mr-2">
ID: {{ member.member_id || 'Pending' }}
</v-chip>
<MultipleCountryFlags
v-if="member.nationality"
:country-codes="member.nationality"
:show-name="false"
size="small"
/>
</div>
</div>
</div>
<!-- Dues Information -->
<div class="dues-info mb-3">
<div v-if="status === 'overdue'">
<!-- Overdue Information -->
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock-alert</v-icon>
Days Overdue
</span>
<span class="text-body-2 font-weight-bold text-error">
{{ calculateDisplayOverdueDays(member) }} days
</span>
</div>
<div v-if="member.overdueReason" class="overdue-reason">
<span class="text-caption text-error">
<v-icon size="12" class="mr-1">mdi-information</v-icon>
{{ member.overdueReason }}
</span>
</div>
<div v-if="member.membership_date_paid" class="d-flex justify-space-between align-center mt-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-calendar-check</v-icon>
Last Payment
</span>
<span class="text-body-2">
{{ formatDate(member.membership_date_paid) }}
</span>
</div>
</div>
<div v-else>
<!-- Upcoming Information -->
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-calendar</v-icon>
Due Date
</span>
<span class="text-body-2 font-weight-bold text-warning">
{{ formatDate(member.nextDueDate || member.payment_due_date || '') }}
</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock</v-icon>
Days Until Due
</span>
<span class="text-body-2 font-weight-bold text-warning">
{{ member.daysUntilDue || 0 }} days
</span>
</div>
</div>
</div>
<!-- Contact Info -->
<div class="contact-info mb-3">
<div v-if="member.email" class="d-flex align-center mb-1">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-body-2 text-truncate">{{ member.email }}</span>
</div>
<div v-if="member.phone" class="d-flex align-center">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-body-2">{{ member.FormattedPhone || member.phone }}</span>
</div>
</div>
</v-card-text>
<!-- Payment Date Selection Dialog -->
<v-dialog v-model="showPaymentDateDialog" max-width="400">
<v-card>
<v-card-title class="text-h6 pa-4">
<v-icon left color="success">mdi-calendar-check</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text class="pa-4">
<div class="mb-4">
<h4 class="text-subtitle-1 mb-2">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h4>
<p class="text-body-2 text-medium-emphasis">
Select the date when the dues payment was received:
</p>
</div>
<v-text-field
v-model="selectedPaymentDate"
label="Payment Date*"
type="date"
:rules="[
v => !!v || 'Payment date is required',
v => !v || new Date(v).getTime() <= new Date().setHours(23,59,59,999) || 'Payment date cannot be in the future'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:max="new Date().toISOString().split('T')[0]"
hint="Select the date when the payment was received"
persistent-hint
/>
<v-alert
v-if="selectedPaymentDate && isDateInFuture"
type="warning"
variant="tonal"
class="mt-2"
density="compact"
>
<v-icon start>mdi-information</v-icon>
Future dates are not allowed. Please select today or an earlier date.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="cancelPaymentDialog"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="elevated"
:disabled="!selectedPaymentDate || isDateInFuture"
:loading="loading"
@click="confirmMarkAsPaid"
>
<v-icon start>mdi-check-circle</v-icon>
Confirm Payment
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Quick Actions -->
<v-card-actions class="pa-4 pt-0 d-flex justify-space-between">
<div class="d-flex gap-1">
<v-btn
variant="text"
size="small"
@click="$emit('view-member', member)"
>
<v-icon start size="16">mdi-account</v-icon>
View Details
</v-btn>
<v-btn
variant="text"
size="small"
:loading="emailLoading"
:disabled="!member.email"
@click="sendDuesReminder"
v-if="member.email"
>
<v-icon start size="16">mdi-email</v-icon>
Email
</v-btn>
</div>
<v-btn
color="success"
variant="elevated"
size="small"
:loading="loading"
@click="showPaymentDateDialog = true"
>
<v-icon start size="16">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
import MultipleCountryFlags from '~/components/MultipleCountryFlags.vue';
// Extended member type for dues management
interface DuesMember {
Id: string;
first_name: string;
last_name: string;
email: string;
phone: string;
nationality?: string;
member_id?: string;
FullName?: string;
FormattedPhone?: string;
overdueDays?: number;
overdueReason?: string;
daysUntilDue?: number;
nextDueDate?: string;
membership_date_paid?: string;
payment_due_date?: string;
current_year_dues_paid?: string;
}
interface Props {
member: DuesMember;
status: 'overdue' | 'upcoming';
loading?: boolean;
}
interface Emits {
(e: 'mark-paid', member: Member): void;
(e: 'view-member', member: DuesMember): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
const emit = defineEmits<Emits>();
// Reactive state for payment date dialog
const showPaymentDateDialog = ref(false);
const selectedPaymentDate = ref('');
const selectedPaymentModel = ref<Date | null>(null);
// Reactive state for email sending
const emailLoading = ref(false);
// Initialize with today's date when dialog opens
watch(showPaymentDateDialog, (isOpen) => {
if (isOpen) {
const today = new Date();
selectedPaymentModel.value = today;
selectedPaymentDate.value = todayDate.value;
}
});
// Date picker handler
const handleDateUpdate = (date: Date | null) => {
if (date) {
selectedPaymentDate.value = date.toISOString().split('T')[0];
}
};
// Computed properties
const memberInitials = computed(() => {
const firstName = props.member.first_name || '';
const lastName = props.member.last_name || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
});
const todayDate = computed(() => {
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
});
const isDateInFuture = computed(() => {
if (!selectedPaymentDate.value) return false;
const selectedDate = new Date(selectedPaymentDate.value);
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset time to start of day
selectedDate.setHours(0, 0, 0, 0); // Reset time to start of day
return selectedDate > today;
});
const avatarColor = computed(() => {
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
const statusColor = computed(() => {
return props.status === 'overdue' ? 'error' : 'warning';
});
const statusIcon = computed(() => {
return props.status === 'overdue' ? 'mdi-alert-circle' : 'mdi-clock-alert';
});
const statusText = computed(() => {
return props.status === 'overdue' ? 'Overdue' : 'Due Soon';
});
const daysDifference = computed(() => {
if (!props.member.payment_due_date) return null;
const today = new Date();
const dueDate = new Date(props.member.payment_due_date);
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
});
// Methods
const calculateDisplayOverdueDays = (member: DuesMember): number => {
// First try to use the pre-calculated overdue days from the API
if (member.overdueDays !== undefined && member.overdueDays > 0) {
return member.overdueDays;
}
// Fallback calculation if not provided
const today = new Date();
const DAYS_IN_YEAR = 365;
// Check if payment is over 1 year old
if (member.membership_date_paid) {
try {
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) {
const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24));
return Math.max(0, daysSincePayment - DAYS_IN_YEAR);
}
} catch {
// Fall through to due date check
}
}
// Check if past due date
if (member.payment_due_date) {
try {
const dueDate = new Date(member.payment_due_date);
if (today > dueDate) {
return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
}
} catch {
// Invalid date
}
}
return 0;
};
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
};
const cancelPaymentDialog = () => {
showPaymentDateDialog.value = false;
selectedPaymentDate.value = '';
};
const confirmMarkAsPaid = async () => {
if (!selectedPaymentDate.value || isDateInFuture.value) return;
try {
// Call the API with the selected payment date
const response = await $fetch<{
success: boolean;
data: Member;
message?: string;
}>(`/api/members/${props.member.Id}/mark-dues-paid`, {
method: 'post',
body: {
paymentDate: selectedPaymentDate.value
}
});
if (response?.success && response.data) {
// Emit the mark-paid event with the updated member data
emit('mark-paid', response.data);
// Close the dialog and reset
showPaymentDateDialog.value = false;
selectedPaymentDate.value = '';
}
} catch (error: any) {
console.error('Error marking dues as paid:', error);
// You could show an error message here if needed
}
};
const sendDuesReminder = async () => {
if (!props.member.email || emailLoading.value) return;
emailLoading.value = true;
try {
// Determine the reminder type based on the member's status
const reminderType = props.status === 'overdue' ? 'overdue' : 'due-soon';
const response = await $fetch<{
success: boolean;
message: string;
data: any;
}>(`/api/members/${props.member.Id}/send-dues-reminder`, {
method: 'post',
body: {
reminderType
}
});
if (response?.success) {
console.log(`Dues reminder sent successfully to ${props.member.email}`);
// You could show a success toast here if needed
}
} catch (error: any) {
console.error('Error sending dues reminder:', error);
// You could show an error toast here if needed
} finally {
emailLoading.value = false;
}
};
</script>
<style scoped>
.dues-action-card {
border-radius: 12px !important;
transition: all 0.3s ease;
position: relative;
height: 100%;
}
.dues-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.dues-action-card--overdue {
border-left: 4px solid rgb(var(--v-theme-error));
}
.dues-action-card--upcoming {
border-left: 4px solid rgb(var(--v-theme-warning));
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
}
.dues-info {
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 12px;
}
.contact-info {
border-radius: 6px;
padding: 8px;
background: rgba(var(--v-theme-surface-variant), 0.05);
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
/* Mobile responsive */
@media (max-width: 600px) {
.dues-action-card {
margin-bottom: 12px;
}
}
</style>

View File

@ -0,0 +1,276 @@
<template>
<v-alert
v-if="overdueCount > 0 && !dismissed"
type="warning"
variant="elevated"
class="dues-overdue-banner mb-6"
prominent
border="start"
>
<template #prepend>
<v-icon size="32">mdi-alert-circle</v-icon>
</template>
<template #title>
<span class="text-h6 font-weight-bold">
Dues Overdue - {{ overdueCount }} Member{{ overdueCount > 1 ? 's' : '' }} Affected
</span>
</template>
<div class="mt-2">
<p class="mb-3">
{{ overdueCount }} member{{ overdueCount > 1 ? 's have' : ' has' }} dues that are more than 1 year overdue.
These accounts have been automatically marked as inactive.
</p>
<!-- Detailed Overdue List -->
<v-expansion-panels
v-if="overdueMembers && overdueMembers.length > 0"
class="mb-4"
variant="accordion"
>
<v-expansion-panel
title="View Overdue Details"
:text="`Click to see all ${overdueCount} overdue members and their specific overdue durations`"
>
<template #text>
<v-list class="pa-0">
<v-list-item
v-for="member in overdueMembers"
:key="member.id"
class="overdue-member-item"
>
<template #prepend>
<ProfileAvatar
:member-id="member.memberId"
:member-name="member.name"
size="small"
class="mr-3"
/>
</template>
<v-list-item-title class="font-weight-medium">
{{ member.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ member.email }}
</v-list-item-subtitle>
<template #append>
<div class="text-right">
<v-chip
:color="member.isInactive ? 'grey' : 'error'"
size="small"
variant="flat"
class="mb-1"
>
<v-icon start size="12">mdi-clock-alert</v-icon>
{{ member.overdueDuration }}
</v-chip>
<br>
<v-chip
:color="member.isInactive ? 'grey' : 'warning'"
size="x-small"
variant="tonal"
>
{{ member.isInactive ? 'Inactive' : member.status }}
</v-chip>
</div>
</template>
</v-list-item>
</v-list>
</template>
</v-expansion-panel>
</v-expansion-panels>
<div class="d-flex flex-wrap gap-2 align-center">
<v-btn
color="warning"
variant="elevated"
size="small"
@click="$emit('view-overdue')"
>
<v-icon start>mdi-eye</v-icon>
View Overdue Members
</v-btn>
<v-btn
v-if="canUpdateStatuses"
color="primary"
variant="outlined"
size="small"
:loading="updatingStatuses"
@click="updateOverdueStatuses"
>
<v-icon start>mdi-refresh</v-icon>
Update Member Statuses
</v-btn>
<v-btn
v-if="canSendReminders"
color="secondary"
variant="outlined"
size="small"
@click="$emit('send-reminders')"
>
<v-icon start>mdi-email-multiple</v-icon>
Send Reminders
</v-btn>
<v-spacer />
<v-btn
icon
size="small"
variant="text"
@click="dismissed = true"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</div>
</v-alert>
</template>
<script setup lang="ts">
import ProfileAvatar from '~/components/ProfileAvatar.vue';
interface OverdueMember {
id: string;
name: string;
email: string;
status: string;
overdueDuration: string;
totalMonthsOverdue: number;
isInactive: boolean;
}
interface Props {
overdueCount: number;
canUpdateStatuses?: boolean;
canSendReminders?: boolean;
refreshTrigger?: number;
}
interface Emits {
(e: 'view-overdue'): void;
(e: 'send-reminders'): void;
(e: 'statuses-updated', count: number): void;
}
const props = withDefaults(defineProps<Props>(), {
canUpdateStatuses: false,
canSendReminders: false,
refreshTrigger: 0
});
const emit = defineEmits<Emits>();
// State
const dismissed = ref(false);
const updatingStatuses = ref(false);
const overdueMembers = ref<OverdueMember[]>([]);
// Load overdue member details
const loadOverdueDetails = async () => {
try {
const response = await $fetch<{
success: boolean;
data: {
count: number;
overdueMembers: OverdueMember[];
};
}>('/api/members/overdue-count');
if (response.success) {
overdueMembers.value = response.data.overdueMembers || [];
}
} catch (error: any) {
console.error('Error loading overdue details:', error);
overdueMembers.value = [];
}
};
// Update overdue member statuses
const updateOverdueStatuses = async () => {
updatingStatuses.value = true;
try {
const response = await $fetch<{
success: boolean;
data: { updatedCount: number };
message?: string;
}>('/api/members/update-overdue-statuses', {
method: 'POST'
});
if (response.success) {
emit('statuses-updated', response.data.updatedCount);
console.log(`Updated ${response.data.updatedCount} overdue member statuses`);
// Refresh overdue details after update
await loadOverdueDetails();
} else {
throw new Error(response.message || 'Failed to update statuses');
}
} catch (error: any) {
console.error('Error updating overdue statuses:', error);
// Show error notification if needed
} finally {
updatingStatuses.value = false;
}
};
// Reset dismissed state when refresh trigger changes
watch(() => props.refreshTrigger, () => {
dismissed.value = false;
loadOverdueDetails(); // Refresh data
});
// Watch for overdueCount changes and reset dismissed
watch(() => props.overdueCount, (newCount, oldCount) => {
if (newCount > oldCount) {
dismissed.value = false;
loadOverdueDetails(); // Load details when count changes
}
});
// Load details on component mount
onMounted(() => {
if (props.overdueCount > 0) {
loadOverdueDetails();
}
});
</script>
<style scoped>
.dues-overdue-banner {
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.2) !important;
}
.dues-overdue-banner :deep(.v-alert__content) {
width: 100%;
}
.gap-2 {
gap: 8px;
}
/* Mobile responsive */
@media (max-width: 600px) {
.d-flex.flex-wrap {
flex-direction: column;
align-items: stretch !important;
}
.d-flex.flex-wrap .v-btn {
margin-bottom: 8px;
}
.v-spacer {
display: none;
}
}
</style>

View File

@ -0,0 +1,622 @@
<template>
<v-banner
v-if="showBanner"
:color="isOverdue ? 'error' : 'warning'"
:icon="isOverdue ? 'mdi-alert-octagon' : 'mdi-alert-circle'"
:class="['dues-payment-banner', { 'overdue-banner': isOverdue }]"
>
<template #text>
<div class="banner-content">
<div class="text-h6 font-weight-bold mb-2">
<v-icon left>{{ isOverdue ? 'mdi-alert-octagon' : 'mdi-credit-card-alert' }}</v-icon>
{{ isOverdue ? '🚨 URGENT: Overdue Dues Payment' : 'Membership Dues Payment Required' }}
</div>
<div class="text-body-1 mb-3">
{{ paymentMessage }}
</div>
<v-card
class="payment-details-card pa-3"
color="rgba(255,255,255,0.95)"
variant="outlined"
>
<div class="text-subtitle-1 font-weight-bold mb-2 text-black">
<v-icon left size="small" class="text-black">mdi-bank</v-icon>
Payment Details
</div>
<v-row dense>
<v-col cols="12" sm="4" md="3">
<div class="text-caption font-weight-bold text-black">Amount:</div>
<div class="text-body-2 text-black">{{ config.membershipFee }}/year</div>
</v-col>
<v-col cols="12" sm="8" md="5" v-if="config.iban">
<div class="text-caption font-weight-bold text-black">IBAN:</div>
<div class="text-body-2 font-family-monospace text-black">{{ config.iban }}</div>
</v-col>
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
<div class="text-caption font-weight-bold text-black">Account Holder:</div>
<div class="text-body-2 text-black">{{ config.accountHolder }}</div>
</v-col>
</v-row>
<v-divider class="my-2 border-opacity-50" />
<v-row dense>
<v-col cols="12">
<div class="text-caption font-weight-bold text-black">Payment Reference:</div>
<div class="text-body-2 font-family-monospace text-black" style="background-color: rgba(0, 0, 0, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #000000;">
{{ memberData?.member_id || 'Member ID pending' }}
</div>
<div class="text-caption text-black mt-1">
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
Please include your member ID in the wire transfer reference for identification
</div>
</v-col>
</v-row>
<v-divider class="my-2 border-opacity-50" />
<div class="text-caption d-flex align-center text-black">
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
{{ daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Payment overdue' }}
before account suspension
</div>
</v-card>
</div>
</template>
<template #actions>
<v-btn
v-if="isAdmin"
color="white"
variant="outlined"
size="small"
@click="markAsPaidDialog = true"
class="mr-2"
>
<v-icon left size="small">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
<v-btn
color="white"
variant="text"
size="small"
@click="dismissBanner"
>
<v-icon left size="small">mdi-close</v-icon>
Dismiss
</v-btn>
</template>
</v-banner>
<!-- Mark as Paid Dialog -->
<v-dialog v-model="markAsPaidDialog" max-width="400">
<v-card>
<v-card-title class="text-h6 pa-4">
<v-icon left color="success">mdi-calendar-check</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text class="pa-4">
<div class="mb-4">
<h4 class="text-subtitle-1 mb-2">
{{ memberData?.FullName || `${memberData?.first_name || ''} ${memberData?.last_name || ''}`.trim() }}
</h4>
<p class="text-body-2 text-medium-emphasis">
Select the date when the dues payment was received:
</p>
</div>
<v-text-field
v-model="selectedPaymentDate"
label="Payment Date*"
type="date"
:rules="[
v => !!v || 'Payment date is required',
v => !v || new Date(v).getTime() <= new Date().setHours(23,59,59,999) || 'Payment date cannot be in the future'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:max="new Date().toISOString().split('T')[0]"
hint="Select the date when the payment was received"
persistent-hint
/>
<v-alert
v-if="selectedPaymentDate && isDateInFuture"
type="warning"
variant="tonal"
class="mt-2"
density="compact"
>
<v-icon start>mdi-information</v-icon>
Future dates are not allowed. Please select today or an earlier date.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="cancelPaymentDialog"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="elevated"
:disabled="!selectedPaymentDate || isDateInFuture"
:loading="updating"
@click="markDuesAsPaid"
>
<v-icon start>mdi-check-circle</v-icon>
Confirm Payment
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="4000"
>
{{ snackbar.message }}
<template #actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</template>
<script setup lang="ts">
import type { RegistrationConfig, Member } from '~/utils/types';
import {
isPaymentOverOneYear as checkPaymentOverOneYear,
isDuesActuallyCurrent as checkDuesActuallyCurrent,
calculateOverdueDays
} from '~/utils/dues-calculations';
// Get auth state
const { user, isAdmin } = useAuth();
// Reactive state
const showBanner = ref(false);
const dismissed = ref(false);
const markAsPaidDialog = ref(false);
const updating = ref(false);
const memberData = ref<Member | null>(null);
const config = ref<RegistrationConfig>({
membershipFee: 50,
iban: '',
accountHolder: ''
});
// Reactive state for payment date dialog
const selectedPaymentDate = ref('');
const selectedPaymentModel = ref<Date | null>(null);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
/**
* Check if a member is in their grace period
* Uses the same logic as dues-status API
*/
const isInGracePeriod = computed(() => {
if (!memberData.value?.payment_due_date) return false;
try {
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
return dueDate > today;
} catch {
return false;
}
});
/**
* Check if a member's last payment is over 1 year old
* Uses standardized dues calculation function
*/
const isPaymentOverOneYear = computed(() => {
if (!memberData.value) return false;
return checkPaymentOverOneYear(memberData.value);
});
/**
* Calculate next dues date (1 year from when they last paid or joined)
*/
const nextDuesDate = computed(() => {
if (!memberData.value) return null;
// If dues are paid, calculate 1 year from payment date
if (memberData.value.current_year_dues_paid === 'true' && memberData.value.membership_date_paid) {
const lastPaidDate = new Date(memberData.value.membership_date_paid);
const nextDue = new Date(lastPaidDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue;
}
// If not paid but has a due date, use that
if (memberData.value.payment_due_date) {
return new Date(memberData.value.payment_due_date);
}
// Fallback: 1 year from member since date
if (memberData.value.member_since) {
const memberSince = new Date(memberData.value.member_since);
const nextDue = new Date(memberSince);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue;
}
return null;
});
/**
* Check if dues are coming due within 30 days (for paid members)
*/
const isDueSoon = computed(() => {
if (!memberData.value || !nextDuesDate.value) return false;
// Only show warning if dues are currently paid
if (memberData.value.current_year_dues_paid !== 'true') return false;
const today = new Date();
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
// Show banner if due date is within the next 30 days
return nextDuesDate.value <= thirtyDaysFromNow && nextDuesDate.value > today;
});
/**
* Check if dues are overdue
* Uses standardized dues calculation function
*/
const isDuesOverdue = computed(() => {
if (!memberData.value) return false;
// Use the standardized function - if not current, then overdue
return !checkDuesActuallyCurrent(memberData.value);
});
/**
* Check if dues need to be paid (either coming due soon or overdue)
*/
const needsPayment = computed(() => {
if (!memberData.value) return false;
// Show banner if dues are coming due soon OR overdue
return isDueSoon.value || isDuesOverdue.value;
});
// Computed properties
const shouldShowBanner = computed(() => {
if (!user.value || !memberData.value) return false;
if (dismissed.value) return false;
// Show banner when payment is needed
return needsPayment.value;
});
const daysRemaining = computed(() => {
if (!nextDuesDate.value) return 0;
const dueDate = nextDuesDate.value;
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays; // Allow negative values for overdue
});
const isOverdue = computed(() => {
return isDuesOverdue.value;
});
const paymentMessage = computed(() => {
if (isDuesOverdue.value) {
const overdueDays = Math.abs(daysRemaining.value);
return `Your annual membership dues of €${config.value.membershipFee} are ${overdueDays > 0 ? overdueDays + ' day' + (overdueDays !== 1 ? 's' : '') + ' ' : ''}overdue. Immediate payment is required to avoid account suspension.`;
} else if (isDueSoon.value) {
const dueDays = daysRemaining.value;
if (dueDays <= 7) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay immediately to avoid late fees.`;
} else {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay soon to avoid account suspension.`;
}
} else {
return `Your annual membership dues of €${config.value.membershipFee} require attention.`;
}
});
const todayDate = computed(() => {
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
});
const isDateInFuture = computed(() => {
if (!selectedPaymentDate.value) return false;
const selectedDate = new Date(selectedPaymentDate.value);
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset time to start of day
selectedDate.setHours(0, 0, 0, 0); // Reset time to start of day
return selectedDate > today;
});
// Methods
function dismissBanner() {
dismissed.value = true;
showBanner.value = false;
// Store dismissal in localStorage (expires after 24 hours)
const dismissalData = {
timestamp: Date.now(),
userId: user.value?.id
};
localStorage.setItem('dues-banner-dismissed', JSON.stringify(dismissalData));
}
async function markDuesAsPaid() {
if (!memberData.value?.Id || !selectedPaymentDate.value || isDateInFuture.value) return;
updating.value = true;
try {
// Call the API with the selected payment date using the correct endpoint
const response = await $fetch<{
success: boolean;
data: any;
message?: string;
}>(`/api/members/${memberData.value.Id}/mark-dues-paid`, {
method: 'post',
body: {
paymentDate: selectedPaymentDate.value
}
});
if (response?.success && response.data) {
// Update local member state
if (memberData.value) {
memberData.value.current_year_dues_paid = 'true';
memberData.value.membership_date_paid = selectedPaymentDate.value;
}
// Hide banner and reset
showBanner.value = false;
markAsPaidDialog.value = false;
selectedPaymentDate.value = '';
selectedPaymentModel.value = null;
// Show success message
snackbar.value = {
show: true,
message: 'Dues marked as paid successfully!',
color: 'success'
};
}
} catch (error: any) {
console.error('Failed to mark dues as paid:', error);
snackbar.value = {
show: true,
message: 'Failed to update payment status. Please try again.',
color: 'error'
};
} finally {
updating.value = false;
}
}
// Initialize with today's date when dialog opens
watch(markAsPaidDialog, (isOpen) => {
if (isOpen) {
const today = new Date();
selectedPaymentModel.value = today;
selectedPaymentDate.value = todayDate.value;
}
});
// Date picker handler
const handleDateUpdate = (date: Date | null) => {
if (date) {
selectedPaymentDate.value = date.toISOString().split('T')[0];
}
};
const cancelPaymentDialog = () => {
markAsPaidDialog.value = false;
selectedPaymentDate.value = '';
selectedPaymentModel.value = null;
};
// Load member data for the current user from session
async function loadMemberData() {
if (!user.value) return;
try {
const response = await $fetch('/api/auth/session') as any;
if (response?.success && response?.member) {
memberData.value = response.member;
}
} catch (error) {
console.warn('Failed to load member data:', error);
}
}
// Load configuration and check banner visibility
async function loadConfig() {
try {
const response = await $fetch('/api/registration-config') as any;
if (response?.success) {
config.value = response.data;
}
} catch (error) {
console.warn('Failed to load registration config:', error);
}
}
// Check if banner was recently dismissed
function checkDismissalStatus() {
try {
const stored = localStorage.getItem('dues-banner-dismissed');
if (stored) {
const dismissalData = JSON.parse(stored);
const hoursSinceDismissal = (Date.now() - dismissalData.timestamp) / (1000 * 60 * 60);
// Reset dismissal after 24 hours or if different user
if (hoursSinceDismissal > 24 || dismissalData.userId !== user.value?.id) {
localStorage.removeItem('dues-banner-dismissed');
dismissed.value = false;
} else {
dismissed.value = true;
}
}
} catch (error) {
console.warn('Failed to check dismissal status:', error);
dismissed.value = false;
}
}
// Watchers
watch(shouldShowBanner, (newVal) => {
showBanner.value = newVal;
}, { immediate: true });
watch(user, () => {
checkDismissalStatus();
loadMemberData();
}, { immediate: true });
// Initialize
onMounted(() => {
loadConfig();
checkDismissalStatus();
loadMemberData();
});
</script>
<style scoped>
.dues-payment-banner {
border-left: 4px solid #ff9800;
}
.dues-payment-banner.overdue-banner {
border-left: 4px solid #f44336;
animation: pulse-border 2s infinite;
}
@keyframes pulse-border {
0% { border-left-color: #f44336; }
50% { border-left-color: #ff5252; }
100% { border-left-color: #f44336; }
}
.banner-content {
width: 100%;
}
.payment-details-card {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
/* Mobile responsiveness */
@media (max-width: 600px) {
.banner-content .text-h6 {
font-size: 1.1rem !important;
}
.payment-details-card {
margin-top: 8px;
}
}
</style>

View File

@ -0,0 +1,732 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
:first-name="member.first_name"
:last-name="member.last_name"
size="large"
class="mr-4"
clickable
show-border
@click="openImageLightbox"
/>
<div class="flex-grow-1">
<h2 class="text-h5 text-white font-weight-bold">
Edit Member: {{ member?.FullName || `${member?.first_name} ${member?.last_name}` }}
</h2>
</div>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-form ref="formRef" v-model="formValid" @submit.prevent="handleSubmit">
<v-row>
<!-- Personal Information Section -->
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Personal Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.first_name"
label="First Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('first_name')"
:error-messages="getFieldError('first_name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.last_name"
label="Last Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('last_name')"
:error-messages="getFieldError('last_name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.email"
label="Email Address"
type="email"
variant="outlined"
:rules="[rules.required, rules.email]"
required
:error="hasFieldError('email')"
:error-messages="getFieldError('email')"
/>
</v-col>
<v-col cols="12" md="6">
<PhoneInputWrapper
v-model="form.phone"
label="Phone Number"
placeholder="Enter phone number"
:error="hasFieldError('phone')"
:error-message="getFieldError('phone')"
@phone-data="handlePhoneData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.date_of_birth"
label="Date of Birth"
type="date"
variant="outlined"
:error="hasFieldError('date_of_birth')"
:error-messages="getFieldError('date_of_birth')"
/>
</v-col>
<v-col cols="12" md="6">
<MultipleNationalityInput
v-model="form.nationality"
label="Nationality"
:error="hasFieldError('nationality')"
:error-message="getFieldError('nationality')"
:max-nationalities="3"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.address"
label="Address"
variant="outlined"
rows="2"
:error="hasFieldError('address')"
:error-messages="getFieldError('address')"
/>
</v-col>
<!-- Membership Information Section -->
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Membership Information</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="form.membership_status"
:items="membershipStatusOptions"
label="Membership Status"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('membership_status')"
:error-messages="getFieldError('membership_status')"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="form.member_since"
label="Member Since"
type="date"
variant="outlined"
:error="hasFieldError('member_since')"
:error-messages="getFieldError('member_since')"
/>
</v-col>
<v-col cols="12" md="4">
<v-switch
v-model="duesPaid"
label="Current Year Dues Paid"
color="success"
inset
:error="hasFieldError('current_year_dues_paid')"
:error-messages="getFieldError('current_year_dues_paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="duesPaid">
<v-text-field
v-model="form.membership_date_paid"
label="Payment Date"
type="date"
variant="outlined"
:error="hasFieldError('membership_date_paid')"
:error-messages="getFieldError('membership_date_paid')"
hint="Enter the actual date when dues were paid (can be historical)"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6" v-if="!duesPaid">
<v-text-field
v-model="form.payment_due_date"
label="Payment Due Date"
type="date"
variant="outlined"
:error="hasFieldError('payment_due_date')"
:error-messages="getFieldError('payment_due_date')"
hint="Enter when payment is due (for members in grace period)"
persistent-hint
/>
</v-col>
<!-- Dues Status Preview -->
<v-col cols="12" v-if="duesPaid && form.membership_date_paid">
<v-card variant="tonal" :color="calculatedDuesStatus.color" class="pa-3">
<div class="d-flex align-center">
<v-icon :color="calculatedDuesStatus.color" class="mr-2">
{{ calculatedDuesStatus.icon }}
</v-icon>
<div>
<div class="text-subtitle-2 font-weight-bold">
Calculated Dues Status: {{ calculatedDuesStatus.text }}
</div>
<div class="text-caption">
{{ calculatedDuesStatus.message }}
</div>
</div>
</div>
</v-card>
</v-col>
<!-- Portal Access Control Section (Admin Only) -->
<template v-if="isAdmin && member?.keycloak_id">
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Portal Access Control</h3>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.portal_group"
:items="portalGroupOptions"
label="Portal Access Level"
variant="outlined"
hint="Controls user's access level in the portal"
persistent-hint
:loading="groupLoading"
:disabled="groupLoading"
:error="hasFieldError('portal_group')"
:error-messages="getFieldError('portal_group')"
>
<template #prepend-inner>
<v-icon color="primary">mdi-shield-account</v-icon>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-alert
v-if="groupSyncStatus"
:type="groupSyncStatus.type"
:text="groupSyncStatus.message"
density="compact"
class="mb-0"
/>
<v-chip
v-else-if="member.keycloak_id"
color="success"
size="small"
class="mt-2"
>
<v-icon start size="small">mdi-check-circle</v-icon>
Portal Account Active
</v-chip>
</v-col>
</template>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-content-save</v-icon>
Save Changes
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Image Lightbox -->
<v-dialog
v-model="showImageLightbox"
max-width="800"
@click:outside="showImageLightbox = false"
>
<v-card class="pa-0" v-if="member && lightboxImageUrl">
<v-card-title class="d-flex align-center pa-4">
<span class="text-h6">{{ member.FullName || `${member.first_name} ${member.last_name}` }} - Profile Photo</span>
<v-spacer />
<v-btn
icon
variant="text"
@click="showImageLightbox = false"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<div class="text-center">
<v-img
:src="lightboxImageUrl"
:alt="`${member.FullName || `${member.first_name} ${member.last_name}`} profile photo`"
max-height="500"
contain
class="mx-auto"
/>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { isPaymentOverOneYear, isDuesActuallyCurrent, calculateOverdueDays } from '~/utils/dues-calculations';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'member-updated', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
// Lightbox state
const showImageLightbox = ref(false);
const lightboxImageUrl = ref<string | null>(null);
// Form data - using snake_case field names
const form = ref({
first_name: '',
last_name: '',
email: '',
phone: '',
date_of_birth: '',
nationality: '',
address: '',
membership_status: 'Active',
member_since: '',
current_year_dues_paid: 'false',
membership_date_paid: '',
payment_due_date: '',
portal_group: 'user'
});
// Additional form state
const duesPaid = ref(false);
const phoneData = ref(null);
// Error handling
const fieldErrors = ref<Record<string, string>>({});
// Computed dues status calculation
const calculatedDuesStatus = computed(() => {
if (!duesPaid.value || !form.value.membership_date_paid) {
return {
color: 'grey',
icon: 'mdi-help',
text: 'Unknown',
message: 'Please enter payment date to calculate status'
};
}
// Create a mock member object with form data to use calculation functions
const mockMember = {
current_year_dues_paid: 'true',
membership_date_paid: form.value.membership_date_paid,
payment_due_date: form.value.payment_due_date,
member_since: form.value.member_since
} as Member;
const isOverdue = !isDuesActuallyCurrent(mockMember);
const paymentTooOld = isPaymentOverOneYear(mockMember);
if (isOverdue && paymentTooOld) {
const overdueDays = calculateOverdueDays(mockMember);
return {
color: 'error',
icon: 'mdi-alert-circle',
text: 'Overdue',
message: `Payment is ${overdueDays} days overdue (more than 1 year since payment)`
};
} else if (isOverdue) {
return {
color: 'warning',
icon: 'mdi-clock-alert',
text: 'Due Soon',
message: 'Dues will be due soon based on payment date'
};
} else {
const paymentDate = new Date(form.value.membership_date_paid);
const nextDue = new Date(paymentDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
const nextDueFormatted = nextDue.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
color: 'success',
icon: 'mdi-check-circle',
text: 'Current',
message: `Dues are current. Next payment due: ${nextDueFormatted}`
};
}
});
// Auth state
const { user, isAdmin } = useAuth();
// Portal group management
const groupLoading = ref(false);
const groupSyncStatus = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
const originalPortalGroup = ref<string>('user');
const portalGroupOptions = [
{ title: 'User - Basic Access', value: 'user' },
{ title: 'Board Member - Extended Access', value: 'board' },
{ title: 'Administrator - Full Access', value: 'admin' }
];
// Watch for portal group changes and sync with Keycloak
watch(() => form.value.portal_group, async (newGroup, oldGroup) => {
if (!props.member?.keycloak_id || !isAdmin || newGroup === oldGroup || newGroup === originalPortalGroup.value) {
return;
}
console.log('[EditMemberDialog] Portal group changed:', oldGroup, '->', newGroup);
groupLoading.value = true;
groupSyncStatus.value = null;
try {
console.log('[EditMemberDialog] Updating Keycloak groups for member:', props.member.Id);
const response = await $fetch(`/api/members/${props.member.Id}/keycloak-groups`, {
method: 'PUT',
body: { newGroup }
});
if (response.success) {
groupSyncStatus.value = {
type: 'success',
message: `Successfully changed access level to ${newGroup}`
};
originalPortalGroup.value = newGroup; // Update original to prevent re-trigger
console.log('[EditMemberDialog] Group change successful:', response.data);
} else {
throw new Error(response.message || 'Failed to update access level');
}
} catch (error: any) {
console.error('[EditMemberDialog] Failed to update Keycloak groups:', error);
groupSyncStatus.value = {
type: 'error',
message: error.data?.message || error.message || 'Failed to update access level'
};
// Revert the form value on error
form.value.portal_group = oldGroup || 'user';
} finally {
groupLoading.value = false;
// Clear status after 5 seconds
setTimeout(() => {
groupSyncStatus.value = null;
}, 5000);
}
});
// Watch dues paid switch
watch(duesPaid, (newValue) => {
form.value.current_year_dues_paid = newValue ? 'true' : 'false';
if (newValue) {
form.value.payment_due_date = '';
} else {
form.value.membership_date_paid = '';
}
});
// Membership status options
const membershipStatusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
// Validation rules
const rules = {
required: (value: any) => {
if (typeof value === 'string') {
return !!value?.trim() || 'This field is required';
}
return !!value || 'This field is required';
},
email: (value: string) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || 'Please enter a valid email address';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const clearFieldErrors = () => {
fieldErrors.value = {};
};
// Phone data handler
const handlePhoneData = (data: any) => {
phoneData.value = data;
};
// Form pre-population - Updated to use snake_case field names
const populateForm = () => {
if (!props.member) return;
console.log('[EditMemberDialog] Populating form with member data:', props.member);
const member = props.member;
// Convert date fields to proper format for input[type="date"]
const formatDateForInput = (dateString: string) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toISOString().split('T')[0];
} catch {
return dateString;
}
};
form.value = {
first_name: member.first_name || '',
last_name: member.last_name || '',
email: member.email || '',
phone: member.phone || '',
date_of_birth: formatDateForInput(member.date_of_birth || ''),
nationality: member.nationality || '',
address: member.address || '',
membership_status: member.membership_status || 'Active',
member_since: formatDateForInput(member.member_since || ''),
current_year_dues_paid: member.current_year_dues_paid || 'false',
membership_date_paid: formatDateForInput(member.membership_date_paid || ''),
payment_due_date: formatDateForInput(member.payment_due_date || ''),
portal_group: member.portal_group || 'user'
};
// Set dues paid switch based on the string value
duesPaid.value = member.current_year_dues_paid === 'true';
console.log('[EditMemberDialog] Form populated:', form.value);
};
// Form submission
const handleSubmit = async () => {
if (!formRef.value || !props.member) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
// Prepare the data for submission
const memberData = { ...form.value };
// Ensure required fields are not empty
if (!memberData.first_name?.trim()) {
throw new Error('First Name is required');
}
if (!memberData.last_name?.trim()) {
throw new Error('Last Name is required');
}
if (!memberData.email?.trim()) {
throw new Error('Email is required');
}
console.log('[EditMemberDialog] Updating member data:', memberData);
const response = await $fetch<{ success: boolean; data: Member; message?: string }>(`/api/members/${props.member.Id}`, {
method: 'PUT',
body: memberData
});
if (response.success && response.data) {
console.log('[EditMemberDialog] Member updated successfully:', response.data);
emit('member-updated', response.data);
closeDialog();
} else {
throw new Error(response.message || 'Failed to update member');
}
} catch (error: any) {
console.error('[EditMemberDialog] Error updating member:', error);
// Handle validation errors
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
// Show general error
fieldErrors.value = {
general: error.message || 'Failed to update member. Please try again.'
};
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
// Watch for dialog open/close and member changes
watch(() => props.modelValue, (newValue) => {
if (newValue && props.member) {
// Dialog opened - populate form with member data
populateForm();
clearFieldErrors();
// Reset form validation
nextTick(() => {
formRef.value?.resetValidation();
});
}
});
watch(() => props.member, (newMember) => {
if (newMember && props.modelValue) {
// Member changed while dialog is open
populateForm();
}
});
// Lightbox functionality
const openImageLightbox = async () => {
if (!props.member?.member_id) return;
try {
// Fetch the original sized image for the lightbox
const response = await $fetch(`/api/profile/image/${props.member.member_id}/medium`) as any;
if (response?.success && response?.imageUrl) {
lightboxImageUrl.value = response.imageUrl;
showImageLightbox.value = true;
}
} catch (error) {
console.warn('Could not load image for lightbox:', error);
// Could show a snackbar here if needed
}
};
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section spacing */
.v-card-text .v-row .v-col:first-child h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Error message styling */
.field-error {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Switch styling */
.v-switch {
flex: none;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.v-dialog {
margin: 16px;
}
}
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@ -0,0 +1,507 @@
<template>
<v-card elevation="2" class="event-calendar">
<v-card-title v-if="!compact" class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-calendar</v-icon>
<span>Events Calendar</span>
</div>
<div v-if="showCreateButton && (isBoard || isAdmin)" class="d-flex gap-2">
<v-btn
@click="$emit('create-event')"
color="primary"
size="small"
prepend-icon="mdi-plus"
>
Create Event
</v-btn>
</div>
</v-card-title>
<v-card-text>
<!-- Mobile view selector -->
<v-row v-if="$vuetify.display.mobile && !compact" class="mb-4">
<v-col cols="12">
<v-btn-toggle
v-model="mobileView"
color="primary"
variant="outlined"
density="comfortable"
mandatory
class="w-100"
>
<v-btn value="week" class="flex-grow-1">
<v-icon start>mdi-calendar-week</v-icon>
Week
</v-btn>
<v-btn value="month" class="flex-grow-1">
<v-icon start>mdi-calendar-month</v-icon>
Month
</v-btn>
<v-btn value="list" class="flex-grow-1">
<v-icon start>mdi-format-list-bulleted</v-icon>
Agenda
</v-btn>
</v-btn-toggle>
</v-col>
</v-row>
<!-- Loading state -->
<v-skeleton-loader
v-if="loading"
type="image"
:height="calendarHeight"
class="rounded"
/>
<!-- FullCalendar component -->
<FullCalendar
v-else
ref="fullCalendar"
:options="calendarOptions"
class="fc-theme-monacousa"
/>
<!-- No events message -->
<v-alert
v-if="!loading && (!events || events.length === 0)"
type="info"
variant="tonal"
class="mt-4"
>
<v-alert-title>No Events</v-alert-title>
No events found for the current time period.
</v-alert>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import listPlugin from '@fullcalendar/list';
import type { Event, FullCalendarEvent } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
interface Props {
events?: Event[];
loading?: boolean;
compact?: boolean;
height?: number | string;
showCreateButton?: boolean;
initialView?: string;
}
const props = withDefaults(defineProps<Props>(), {
events: () => [],
loading: false,
compact: false,
height: 600,
showCreateButton: true,
initialView: 'dayGridMonth'
});
const emit = defineEmits<{
'event-click': [event: any];
'date-click': [date: any];
'view-change': [view: any];
'date-range-change': [start: string, end: string];
'create-event': [];
}>();
const { isBoard, isAdmin } = useAuth();
// Reactive state
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
const mobileView = ref('week'); // Default to week view on mobile
// Computed properties
const calendarHeight = computed(() => {
if (props.compact) return props.height || 300;
if (typeof props.height === 'number') return props.height;
return props.height || 600;
});
const currentView = computed(() => {
if (props.compact) return 'dayGridMonth';
// Mobile responsive view switching
if (process.client && window.innerWidth < 960) {
switch (mobileView.value) {
case 'week': return 'dayGridWeek';
case 'list': return 'listWeek';
case 'month':
default: return 'dayGridMonth';
}
}
return props.initialView;
});
const transformedEvents = computed((): FullCalendarEvent[] => {
console.log('[EventCalendar] Raw events received:', props.events.length);
console.log('[EventCalendar] Raw events array:', props.events);
props.events.forEach((event, index) => {
console.log(`[EventCalendar] Event ${index + 1}:`, {
id: event.id,
title: event.title,
start_datetime: event.start_datetime,
end_datetime: event.end_datetime,
event_type: event.event_type
});
});
const transformed = props.events.map((event: Event) => transformEventForCalendar(event));
console.log('[EventCalendar] Transformed events for FullCalendar:', transformed.length);
console.log('[EventCalendar] Transformed events array:', transformed);
transformed.forEach((event, index) => {
console.log(`[EventCalendar] Transformed Event ${index + 1}:`, {
id: event.id,
title: event.title,
start: event.start,
end: event.end,
backgroundColor: event.backgroundColor
});
});
return transformed;
});
// FullCalendar options
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, interactionPlugin, listPlugin],
initialView: currentView.value,
height: calendarHeight.value,
headerToolbar: props.compact ? false : {
left: 'prev,next today',
center: 'title',
right: process.client && window.innerWidth < 960 ?
'dayGridMonth,listWeek' :
'dayGridMonth,dayGridWeek,listWeek'
} as any,
events: transformedEvents.value,
eventClick: handleEventClick,
dateClick: handleDateClick,
datesSet: handleDatesSet,
eventDidMount: handleEventMount,
dayMaxEvents: props.compact ? 2 : 5,
eventDisplay: 'block',
displayEventTime: true,
eventTimeFormat: {
hour: '2-digit' as const,
minute: '2-digit' as const,
hour12: false
},
locale: 'en',
firstDay: 1, // Monday
weekends: true,
navLinks: true,
selectable: isBoard.value || isAdmin.value,
selectMirror: true,
select: handleDateSelect,
// Mobile optimizations
aspectRatio: process.client && window.innerWidth < 960 ? 1.0 : 1.35,
// Responsive behavior
windowResizeDelay: 100
}));
// Event handlers
function handleEventClick(clickInfo: any) {
emit('event-click', {
event: clickInfo.event,
eventData: clickInfo.event.extendedProps
});
}
function handleDateClick(dateInfo: any) {
if (isBoard.value || isAdmin.value) {
emit('date-click', {
date: dateInfo.dateStr,
allDay: dateInfo.allDay
});
}
}
function handleDateSelect(selectInfo: any) {
if (isBoard.value || isAdmin.value) {
emit('date-click', {
date: selectInfo.startStr,
endDate: selectInfo.endStr,
allDay: selectInfo.allDay
});
}
}
function handleDatesSet(dateInfo: any) {
emit('view-change', {
view: dateInfo.view.type,
start: dateInfo.start,
end: dateInfo.end
});
emit('date-range-change',
dateInfo.start.toISOString(),
dateInfo.end.toISOString()
);
}
function handleEventMount(mountInfo: any) {
// Add custom styling or tooltips
const event = mountInfo.event;
const el = mountInfo.el;
// Add tooltip with event details
el.setAttribute('title', `${event.title}\n${event.extendedProps.location || 'No location'}`);
// Add custom classes based on event properties
if (event.extendedProps.is_paid) {
el.classList.add('fc-paid-event');
}
if (event.extendedProps.user_rsvp?.rsvp_status === 'confirmed') {
el.classList.add('fc-user-rsvp');
}
}
// Transform event data for FullCalendar
function transformEventForCalendar(event: Event): FullCalendarEvent {
console.log('[EventCalendar] Transforming event:', {
id: event.id,
event_id: event.event_id,
title: event.title,
start_datetime: event.start_datetime,
end_datetime: event.end_datetime,
event_type: event.event_type
});
const eventTypeColors = {
'meeting': { bg: '#2196f3', border: '#1976d2' },
'social': { bg: '#4caf50', border: '#388e3c' },
'fundraiser': { bg: '#ff9800', border: '#f57c00' },
'workshop': { bg: '#9c27b0', border: '#7b1fa2' },
'board-only': { bg: '#a31515', border: '#8b1212' }
};
const colors = eventTypeColors[event.event_type] ||
{ bg: '#757575', border: '#424242' };
// Use event_id as the primary identifier for FullCalendar uniqueness
const calendarId = event.event_id || event.id || `temp_${(event as any).Id}_${Date.now()}`;
console.log('[EventCalendar] Using calendar ID:', calendarId, 'from event_id:', event.event_id, 'fallback id:', event.id);
// Ensure dates are properly formatted for FullCalendar
let startDate: string | Date;
let endDate: string | Date;
try {
// Convert to Date objects first to validate, then use ISO strings
const startDateObj = new Date(event.start_datetime);
const endDateObj = new Date(event.end_datetime);
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
console.error('[EventCalendar] Invalid date values for event:', calendarId, {
start: event.start_datetime,
end: event.end_datetime
});
// Use fallback dates
startDate = new Date().toISOString();
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
} else {
startDate = startDateObj.toISOString();
endDate = endDateObj.toISOString();
}
} catch (error) {
console.error('[EventCalendar] Date parsing error for event:', calendarId, error);
// Use fallback dates
startDate = new Date().toISOString();
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
}
const transformedEvent = {
id: calendarId, // Use event_id instead of event.id
title: event.title,
start: startDate,
end: endDate,
backgroundColor: colors.bg,
borderColor: colors.border,
textColor: '#ffffff',
extendedProps: {
originalEvent: event, // Store original event for debugging
description: event.description,
location: event.location,
event_type: event.event_type,
is_paid: event.is_paid === 'true',
cost_members: event.cost_members,
cost_non_members: event.cost_non_members,
max_attendees: event.max_attendees ? parseInt(event.max_attendees) : undefined,
current_attendees: typeof event.current_attendees === 'string' ? parseInt(event.current_attendees) : (event.current_attendees || 0),
user_rsvp: event.user_rsvp,
visibility: event.visibility,
creator: event.creator,
event_id: event.event_id, // Store for reference
database_id: event.id || (event as any).Id
}
};
console.log('[EventCalendar] Transformed event result:', {
id: transformedEvent.id,
title: transformedEvent.title,
start: transformedEvent.start,
end: transformedEvent.end,
backgroundColor: transformedEvent.backgroundColor
});
return transformedEvent;
}
// Public methods
function getCalendarApi() {
return fullCalendar.value?.getApi();
}
function refetchEvents() {
const api = getCalendarApi();
if (api) {
api.refetchEvents();
}
}
function changeView(viewType: string) {
const api = getCalendarApi();
if (api) {
api.changeView(viewType);
}
}
function gotoDate(date: string | Date) {
const api = getCalendarApi();
if (api) {
api.gotoDate(date);
}
}
// Watch for mobile view changes
watch(mobileView, (newView) => {
let viewType;
switch (newView) {
case 'week': viewType = 'dayGridWeek'; break;
case 'list': viewType = 'listWeek'; break;
case 'month':
default: viewType = 'dayGridMonth'; break;
}
changeView(viewType);
});
// Expose methods to parent components
defineExpose({
getCalendarApi,
refetchEvents,
changeView,
gotoDate
});
</script>
<style scoped>
.event-calendar :deep(.fc) {
font-family: 'Roboto', sans-serif;
}
.event-calendar :deep(.fc-theme-standard .fc-scrollgrid) {
border-color: rgba(0, 0, 0, 0.12);
}
.event-calendar :deep(.fc-theme-standard td),
.event-calendar :deep(.fc-theme-standard th) {
border-color: rgba(0, 0, 0, 0.12);
}
.event-calendar :deep(.fc-button-primary) {
background-color: #a31515;
border-color: #a31515;
font-weight: 500;
text-transform: none;
}
.event-calendar :deep(.fc-button-primary:hover) {
background-color: #8b1212;
border-color: #8b1212;
}
.event-calendar :deep(.fc-button-primary:disabled) {
background-color: rgba(163, 21, 21, 0.5);
border-color: rgba(163, 21, 21, 0.5);
}
.event-calendar :deep(.fc-today-button) {
font-weight: 500;
text-transform: none;
}
.event-calendar :deep(.fc-toolbar-title) {
font-size: 1.25rem;
font-weight: 600;
color: #a31515;
}
.event-calendar :deep(.fc-day-today) {
background-color: rgba(163, 21, 21, 0.05) !important;
}
.event-calendar :deep(.fc-event) {
border-radius: 4px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
}
.event-calendar :deep(.fc-event:hover) {
opacity: 0.85;
}
.event-calendar :deep(.fc-paid-event) {
border-left: 4px solid #ff9800 !important;
}
.event-calendar :deep(.fc-user-rsvp) {
box-shadow: 0 0 0 2px #4caf50;
}
.event-calendar :deep(.fc-list-event-title) {
font-weight: 500;
}
.event-calendar :deep(.fc-list-event-time) {
font-weight: 600;
color: #a31515;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.event-calendar :deep(.fc-toolbar) {
flex-direction: column;
gap: 8px;
}
.event-calendar :deep(.fc-toolbar-chunk) {
display: flex;
justify-content: center;
}
.event-calendar :deep(.fc-button-group) {
display: flex;
}
.event-calendar :deep(.fc-button) {
padding: 4px 8px;
font-size: 0.75rem;
}
.event-calendar :deep(.fc-toolbar-title) {
font-size: 1.1rem;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,809 @@
<template>
<v-dialog v-model="show" max-width="600" persistent>
<v-card v-if="event">
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2" :color="eventTypeColor">{{ eventTypeIcon }}</v-icon>
<span>{{ event?.title || 'Event Details' }}</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<!-- Event Type Badge -->
<v-chip
:color="eventTypeColor"
size="small"
variant="tonal"
class="mb-4"
>
<v-icon start>{{ eventTypeIcon }}</v-icon>
{{ eventTypeLabel }}
</v-chip>
<!-- Event Details -->
<v-row class="mb-4">
<!-- Date & Time -->
<v-col cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-calendar-clock</v-icon>
<div>
<div class="font-weight-medium">{{ formatEventDate }}</div>
<div class="text-body-2 text-medium-emphasis">{{ formatEventTime }}</div>
</div>
</div>
</v-col>
<!-- Location -->
<v-col v-if="event.location" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-map-marker</v-icon>
<span>{{ event.location }}</span>
</div>
</v-col>
<!-- Description -->
<v-col v-if="event.description" cols="12">
<div class="d-flex align-start mb-2">
<v-icon class="me-2 mt-1">mdi-text</v-icon>
<div>
<div class="font-weight-medium mb-1">Description</div>
<!-- Display HTML content safely -->
<div
class="text-body-2 rich-text-content"
v-html="event.description"
/>
</div>
</div>
</v-col>
<!-- Capacity -->
<v-col v-if="event.max_attendees" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-account-group</v-icon>
<div>
<span class="font-weight-medium">Capacity:</span>
<span class="ms-2">
{{ event.current_attendees || 0 }} / {{ event.max_attendees }}
</span>
<v-progress-linear
:model-value="capacityPercentage"
:color="capacityColor"
height="4"
class="mt-1"
rounded
/>
</div>
</div>
</v-col>
</v-row>
<!-- Payment Information -->
<v-alert
v-if="event.is_paid === 'true'"
type="info"
variant="tonal"
class="mb-4"
>
<v-alert-title>
<v-icon start>mdi-currency-eur</v-icon>
Payment Required
</v-alert-title>
<div class="mt-2">
<div v-if="memberPrice && nonMemberPrice">
<strong>Members:</strong> {{ memberPrice }}<br>
<strong>Non-Members:</strong> {{ nonMemberPrice }}
</div>
<div v-else-if="memberPrice">
<strong>Cost:</strong> {{ memberPrice }}
</div>
<div v-if="event.member_pricing_enabled === 'false'" class="text-caption mt-1">
<v-icon size="small">mdi-information</v-icon>
Member pricing is not available for this event
</div>
</div>
</v-alert>
<!-- RSVP Status -->
<v-card
v-if="hasRSVP"
variant="outlined"
class="mb-4"
:color="rsvpStatusColor"
>
<v-card-text class="py-3">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon :color="rsvpStatusColor" class="me-2">{{ rsvpStatusIcon }}</v-icon>
<div>
<div class="font-weight-medium">{{ rsvpStatusText }}</div>
<div v-if="userRSVP?.rsvp_notes" class="text-caption">{{ userRSVP.rsvp_notes }}</div>
</div>
</div>
<v-btn
@click="changeRSVP"
size="small"
variant="outlined"
:color="rsvpStatusColor"
>
Change
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Payment Details (if RSVP'd to paid event) -->
<v-card
v-if="showPaymentDetails"
variant="outlined"
class="mb-4"
>
<v-card-title class="py-3">
<v-icon class="me-2">mdi-bank-transfer</v-icon>
Payment Details
</v-card-title>
<v-card-text class="pt-0">
<v-row dense>
<v-col cols="12">
<div class="text-body-2">
<strong>Amount:</strong> {{ paymentAmount }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>IBAN:</strong> {{ paymentInfo.iban }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Recipient:</strong> {{ paymentInfo.recipient }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Reference:</strong> {{ userRSVP?.payment_reference }}
</div>
</v-col>
</v-row>
<v-btn
@click="copyPaymentDetails"
size="small"
variant="outlined"
class="mt-3"
prepend-icon="mdi-content-copy"
>
Copy Details
</v-btn>
</v-card-text>
</v-card>
<!-- RSVP Form -->
<v-card v-if="!hasRSVP && canRSVP" variant="outlined">
<v-card-title class="py-3">
<v-icon class="me-2">mdi-account-check</v-icon>
RSVP to this Event
</v-card-title>
<v-card-text class="pt-0">
<v-form v-model="rsvpValid">
<!-- Guest Selection (if event allows guests) -->
<div v-if="allowsGuests" class="mb-4">
<v-card variant="tonal" class="mb-3">
<v-card-text class="py-3">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-account-group</v-icon>
<span class="font-weight-medium">Bring Guests</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-3">
This event allows up to {{ maxGuestsAllowed }} additional guests per person.
</p>
<v-select
v-model="selectedGuests"
:items="guestOptions"
label="Number of Additional Guests"
variant="outlined"
density="compact"
/>
</v-card-text>
</v-card>
</div>
<v-textarea
v-model="rsvpNotes"
label="Notes (optional)"
rows="2"
variant="outlined"
class="mb-3"
/>
<v-btn
@click="submitRSVP('confirmed')"
color="primary"
:loading="rsvpLoading"
:disabled="isEventFull && !isWaitlistAvailable"
size="large"
block
class="mb-2"
>
<v-icon start>mdi-check</v-icon>
{{ isEventFull ? 'Join Waitlist' : 'RSVP' }}
</v-btn>
</v-form>
</v-card-text>
</v-card>
<!-- Event Full Message -->
<v-alert
v-if="isEventFull && !hasRSVP && !isWaitlistAvailable"
type="warning"
variant="tonal"
>
<v-alert-title>Event Full</v-alert-title>
This event has reached maximum capacity and waitlist is not available.
</v-alert>
<!-- Past Event Message -->
<v-alert
v-if="isPastEvent"
type="info"
variant="tonal"
>
<v-alert-title>Past Event</v-alert-title>
This event has already occurred.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<!-- Delete button for admin/board -->
<v-btn
v-if="canDeleteEvent"
@click="showDeleteConfirm = true"
color="error"
variant="outlined"
prepend-icon="mdi-delete"
:loading="deleteLoading"
>
Delete Event
</v-btn>
<v-spacer />
<v-btn
@click="close"
variant="outlined"
>
Close
</v-btn>
</v-card-actions>
</v-card>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteConfirm" max-width="500" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="me-2 text-error">mdi-alert</v-icon>
Delete Event
</v-card-title>
<v-card-text>
<v-alert type="warning" variant="tonal" class="mb-4">
<v-alert-title>Warning: This action cannot be undone</v-alert-title>
This will permanently delete the event and all associated RSVP data.
</v-alert>
<p class="text-body-1 mb-4">
Are you sure you want to delete "<strong>{{ event?.title }}</strong>"?
</p>
<div class="text-body-2 text-medium-emphasis">
<div v-if="event?.current_attendees && parseInt(event.current_attendees) > 0">
<v-icon size="small" class="me-1">mdi-information</v-icon>
This event has {{ event.current_attendees }} confirmed attendees. Their RSVPs will also be deleted.
</div>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="showDeleteConfirm = false"
variant="outlined"
:disabled="deleteLoading"
>
Cancel
</v-btn>
<v-btn
@click="handleDeleteEvent"
color="error"
:loading="deleteLoading"
>
Delete Event
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
import { useEvents } from '~/composables/useEvents';
import { useAuth } from '~/composables/useAuth';
// Helper function to replace date-fns format
const formatDate = (date: Date, formatStr: string): string => {
if (formatStr === 'EEEE, MMMM d, yyyy') {
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
} else if (formatStr === 'MMM d') {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
} else if (formatStr === 'MMM d, yyyy') {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
} else if (formatStr === 'HH:mm') {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
return date.toLocaleDateString();
};
interface Props {
modelValue: boolean;
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'rsvp-updated': [event: Event];
}>();
const { rsvpToEvent, deleteEvent } = useEvents();
const { isAdmin, isBoard } = useAuth();
// Reactive state
const rsvpValid = ref(false);
const rsvpLoading = ref(false);
const rsvpNotes = ref('');
const selectedGuests = ref(0);
const deleteLoading = ref(false);
const showDeleteConfirm = ref(false);
// Computed properties
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const hasRSVP = computed(() => !!userRSVP.value);
const canRSVP = computed(() => {
return props.event && !isPastEvent.value;
});
const isPastEvent = computed(() => {
if (!props.event) return false;
return new Date(props.event.start_datetime) < new Date();
});
const isEventFull = computed(() => {
if (!props.event?.max_attendees) return false;
const maxAttendees = parseInt(props.event.max_attendees);
const currentAttendees = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: props.event.current_attendees || 0;
return currentAttendees >= maxAttendees;
});
const isWaitlistAvailable = computed(() => true); // Always allow waitlist for now
const eventTypeColor = computed(() => {
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event?.event_type as keyof typeof colors] || 'grey';
});
const eventTypeIcon = computed(() => {
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event?.event_type as keyof typeof icons] || 'mdi-calendar';
});
const eventTypeLabel = computed(() => {
const labels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Only'
};
return labels[props.event?.event_type as keyof typeof labels] || 'Event';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
if (startDate.toDateString() === endDate.toDateString()) {
return formatDate(startDate, 'EEEE, MMMM d, yyyy');
} else {
return `${formatDate(startDate, 'MMM d')} - ${formatDate(endDate, 'MMM d, yyyy')}`;
}
});
const formatEventTime = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
return `${formatDate(startDate, 'HH:mm')} - ${formatDate(endDate, 'HH:mm')}`;
});
const capacityPercentage = computed(() => {
if (!props.event?.max_attendees) return 0;
const max = parseInt(props.event.max_attendees);
const current = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: props.event.current_attendees || 0;
return (current / max) * 100;
});
const capacityColor = computed(() => {
const percentage = capacityPercentage.value;
if (percentage >= 100) return 'error';
if (percentage >= 80) return 'warning';
return 'success';
});
const memberPrice = computed(() => props.event?.cost_members);
const nonMemberPrice = computed(() => props.event?.cost_non_members);
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check-circle';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close-circle';
default: return 'mdi-help-circle';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'You are attending this event';
case 'waitlist': return 'You are on the waitlist';
case 'declined': return 'You declined this event';
default: return 'Status unknown';
}
});
const showPaymentDetails = computed(() => {
return props.event?.is_paid === 'true' &&
userRSVP.value?.rsvp_status === 'confirmed' &&
userRSVP.value?.payment_status === 'pending';
});
const paymentAmount = computed(() => {
if (!userRSVP.value || !props.event) return '0';
const isMemberPricing = userRSVP.value.is_member_pricing === 'true';
return isMemberPricing ? props.event.cost_members : props.event.cost_non_members;
});
const paymentInfo = computed(() => ({
iban: 'FR76 1234 5678 9012 3456 7890 123', // This should come from config
recipient: 'MonacoUSA Association' // This should come from config
}));
// Guest functionality
const allowsGuests = computed(() => {
return props.event?.guests_permitted === 'true';
});
const maxGuestsAllowed = computed(() => {
if (!allowsGuests.value) return 0;
return parseInt(props.event?.max_guests_permitted || '0');
});
const guestOptions = computed(() => {
const max = maxGuestsAllowed.value;
const options = [];
for (let i = 0; i <= max; i++) {
options.push({
title: i === 0 ? 'No additional guests' : `${i} guest${i > 1 ? 's' : ''}`,
value: i
});
}
return options;
});
// Admin/Board permissions
const canDeleteEvent = computed(() => {
console.log('[EventDetailsDialog] canDeleteEvent computed triggered');
console.log('[EventDetailsDialog] Auth composable values:', {
isAdmin: isAdmin.value,
isBoard: isBoard.value,
typeof_isAdmin: typeof isAdmin.value,
typeof_isBoard: typeof isBoard.value
});
const canDelete = isAdmin.value || isBoard.value;
console.log('[EventDetailsDialog] Final canDelete result:', canDelete);
return canDelete;
});
// Add watcher to see when dialog opens
watch(() => show.value, (newValue) => {
if (newValue) {
console.log('[EventDetailsDialog] Dialog opened');
console.log('[EventDetailsDialog] Event prop:', props.event);
console.log('[EventDetailsDialog] Auth status check on open:', {
isAdmin: isAdmin.value,
isBoard: isBoard.value,
canDelete: canDeleteEvent.value
});
}
});
// Methods
const close = () => {
show.value = false;
rsvpNotes.value = '';
};
const submitRSVP = async (status: 'confirmed' | 'declined') => {
console.log('[EventDetailsDialog] submitRSVP called with status:', status);
if (!props.event) {
console.error('[EventDetailsDialog] No event provided');
return;
}
rsvpLoading.value = true;
try {
// Use event_id field for consistent RSVP relationships
// This ensures RSVPs are linked properly to events using the business identifier
let eventId = props.event.event_id ||
(props.event as any).extendedProps?.event_id ||
(props.event as any).Id || // Fallback to database ID if event_id not available
props.event.id ||
(props.event as any).id; // Additional fallback
// Direct access to Id field as backup
if (!eventId && 'Id' in props.event) {
eventId = (props.event as any)['Id'];
console.log('[EventDetailsDialog] Found Id via direct property access:', eventId);
}
// Try to access the Id property using Object.keys approach
if (!eventId) {
const keys = Object.keys(props.event);
console.log('[EventDetailsDialog] Available keys:', keys);
if (keys.includes('Id')) {
eventId = props.event['Id' as keyof Event];
console.log('[EventDetailsDialog] Found Id via keys lookup:', eventId);
}
}
console.log('[EventDetailsDialog] Using event identifier for RSVP:', eventId);
console.log('[EventDetailsDialog] Event object keys:', Object.keys(props.event));
console.log('[EventDetailsDialog] Event event_id field:', props.event.event_id);
console.log('[EventDetailsDialog] Event database Id field:', (props.event as any).Id);
console.log('[EventDetailsDialog] Event id field:', props.event.id);
console.log('[EventDetailsDialog] Full event object:', JSON.stringify(props.event, null, 2));
if (!eventId) {
console.error('[EventDetailsDialog] Unable to determine event identifier');
throw new Error('Unable to determine event identifier');
}
console.log('[EventDetailsDialog] Calling rsvpToEvent with:', {
eventId,
status,
notes: rsvpNotes.value,
guests: selectedGuests.value
});
const result = await rsvpToEvent(eventId, {
member_id: '', // This will be filled by the composable
rsvp_status: status,
rsvp_notes: rsvpNotes.value,
extra_guests: selectedGuests.value.toString()
});
console.log('[EventDetailsDialog] RSVP submitted successfully:', result);
emit('rsvp-updated', props.event);
// TODO: Show success message
} catch (error) {
console.error('[EventDetailsDialog] Error submitting RSVP:', error);
// TODO: Show error message
} finally {
rsvpLoading.value = false;
}
};
const changeRSVP = () => {
// For now, just allow re-submitting RSVP
// In the future, this could open an edit dialog
if (userRSVP.value?.rsvp_status === 'confirmed') {
submitRSVP('declined');
} else if (userRSVP.value?.rsvp_status === 'declined') {
submitRSVP('confirmed');
}
};
const copyPaymentDetails = async () => {
const details = `
Event: ${props.event?.title}
Amount: ${paymentAmount.value}
IBAN: ${paymentInfo.value.iban}
Recipient: ${paymentInfo.value.recipient}
Reference: ${userRSVP.value?.payment_reference}
`.trim();
try {
await navigator.clipboard.writeText(details);
} catch (error) {
console.error('Error copying to clipboard:', error);
}
};
const handleDeleteEvent = async () => {
if (!props.event) return;
deleteLoading.value = true;
try {
// Use the correct event identifier for deletion
const eventId = (props.event as any).Id || props.event.id || props.event.event_id;
if (!eventId) {
throw new Error('Unable to determine event ID for deletion');
}
console.log('[EventDetailsDialog] Deleting event with ID:', eventId);
const result = await deleteEvent(eventId.toString());
console.log('[EventDetailsDialog] Event deleted successfully:', result);
// Close both dialogs
showDeleteConfirm.value = false;
show.value = false;
// Emit event for parent component to refresh
emit('rsvp-updated', props.event);
} catch (error) {
console.error('[EventDetailsDialog] Error deleting event:', error);
// TODO: Show error message to user
} finally {
deleteLoading.value = false;
}
};
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.text-medium-emphasis {
opacity: 0.7;
}
.v-progress-linear {
max-width: 200px;
}
/* Rich text content styling */
.rich-text-content {
word-wrap: break-word;
line-height: 1.5;
}
.rich-text-content :deep(h1),
.rich-text-content :deep(h2),
.rich-text-content :deep(h3) {
color: rgb(var(--v-theme-on-surface));
font-weight: 600;
margin: 16px 0 8px 0;
}
.rich-text-content :deep(h1) {
font-size: 1.5rem;
}
.rich-text-content :deep(h2) {
font-size: 1.25rem;
}
.rich-text-content :deep(h3) {
font-size: 1.125rem;
}
.rich-text-content :deep(p) {
margin: 8px 0;
}
.rich-text-content :deep(ul),
.rich-text-content :deep(ol) {
padding-left: 20px;
margin: 8px 0;
}
.rich-text-content :deep(li) {
margin: 4px 0;
}
.rich-text-content :deep(strong) {
font-weight: 600;
}
.rich-text-content :deep(em) {
font-style: italic;
}
.rich-text-content :deep(u) {
text-decoration: underline;
}
.rich-text-content :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.rich-text-content :deep(a:hover) {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,201 @@
<template>
<v-dialog v-model="show" max-width="400" persistent>
<v-card>
<v-card-title class="text-h5 text-center pa-6" style="color: #a31515;">
Reset Password
</v-card-title>
<v-card-text class="px-6">
<p class="text-body-2 mb-4 text-center text-medium-emphasis">
Enter your email address and we'll send you a link to reset your password.
</p>
<v-form @submit.prevent="handleSubmit" ref="resetForm">
<v-text-field
v-model="email"
label="Email Address"
type="email"
prepend-inner-icon="mdi-email"
variant="outlined"
:error-messages="errors.email"
:disabled="loading"
required
@input="clearErrors"
/>
<v-alert
v-if="message"
:type="messageType"
class="mb-4"
variant="tonal"
>
{{ message }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="px-6 pb-6">
<v-btn
variant="text"
@click="close"
:disabled="loading"
>
Cancel
</v-btn>
<v-spacer />
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!email || !isValidEmail"
style="background-color: #a31515 !important;"
>
Send Reset Link
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'success', message: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Reactive data
const email = ref('');
const loading = ref(false);
const message = ref('');
const messageType = ref<'success' | 'error' | 'warning' | 'info'>('info');
const errors = ref({
email: ''
});
const resetForm = ref();
// Computed
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.value);
});
// Methods
const clearErrors = () => {
errors.value.email = '';
message.value = '';
};
const validateEmail = () => {
errors.value.email = '';
if (!email.value) {
errors.value.email = 'Email is required';
return false;
}
if (!isValidEmail.value) {
errors.value.email = 'Please enter a valid email address';
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validateEmail()) return;
loading.value = true;
message.value = '';
try {
const response = await $fetch<{
success: boolean;
message: string;
}>('/api/auth/forgot-password', {
method: 'POST',
body: {
email: email.value
}
});
if (response.success) {
message.value = response.message;
messageType.value = 'success';
// Emit success event
emit('success', response.message);
// Auto-close after 3 seconds
setTimeout(() => {
close();
}, 3000);
}
} catch (error: any) {
console.error('Password reset error:', error);
message.value = error.data?.message || 'Failed to send reset email. Please try again.';
messageType.value = 'error';
} finally {
loading.value = false;
}
};
const close = () => {
show.value = false;
// Reset form after dialog closes
setTimeout(() => {
email.value = '';
message.value = '';
errors.value.email = '';
loading.value = false;
}, 300);
};
// Auto-focus email field when dialog opens
watch(show, (newValue) => {
if (newValue) {
nextTick(() => {
const emailField = document.querySelector('input[type="email"]') as HTMLInputElement;
if (emailField) {
emailField.focus();
}
});
}
});
</script>
<style scoped>
.v-card {
border-radius: 16px !important;
}
.v-card-title {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.v-btn {
text-transform: none !important;
}
/* Form field focus styles */
.v-field--focused {
border-color: #a31515 !important;
}
.v-field--focused .v-field__outline {
border-color: #a31515 !important;
}
</style>

633
components/MemberCard.vue Normal file
View File

@ -0,0 +1,633 @@
<template>
<v-card
class="member-card"
:class="{
'member-card--inactive': !isActive,
'member-card--overdue': isOverdue,
'member-card--due-soon': isDuesComingDue
}"
elevation="2"
@click="$emit('view', member)"
>
<!-- Status Stripe -->
<div
v-if="isOverdue || isDuesComingDue"
class="status-stripe"
:class="{
'status-stripe--overdue': isOverdue,
'status-stripe--due-soon': isDuesComingDue
}"
/>
<!-- Member Status Badge -->
<div class="member-status-badge">
<v-chip
:color="statusColor"
size="small"
variant="flat"
class="font-weight-bold"
>
<v-icon v-if="!isActive" start size="12">mdi-account-off</v-icon>
<v-icon v-else start size="12">mdi-account-check</v-icon>
{{ member.membership_status || 'Inactive' }}
</v-chip>
</div>
<!-- Action Buttons -->
<div v-if="canEdit || canDelete || (!member.keycloak_id && canCreatePortalAccount) || shouldShowEmailButton" class="member-action-buttons">
<!-- Email Button for Overdue/Due Soon Members -->
<v-btn
v-if="shouldShowEmailButton"
icon
size="small"
variant="text"
:color="isOverdue ? 'error' : 'warning'"
:loading="emailLoading"
@click.stop="sendDuesReminder"
:title="'Send Dues Reminder to ' + member.FullName"
>
<v-icon>mdi-email-alert</v-icon>
</v-btn>
<v-btn
v-if="canEdit"
icon
size="small"
variant="text"
@click.stop="$emit('edit', member)"
:title="'Edit ' + member.FullName"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
v-if="canDelete"
icon
size="small"
variant="text"
color="error"
@click.stop="$emit('delete', member)"
:title="'Delete ' + member.FullName"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<!-- Create Portal Account Button (Circular) -->
<v-btn
v-if="!member.keycloak_id && canCreatePortalAccount"
icon
size="small"
variant="text"
color="primary"
:loading="creatingPortalAccount"
@click.stop="$emit('create-portal-account', member)"
:title="'Create Portal Account for ' + member.FullName"
>
<v-icon>mdi-account-plus</v-icon>
</v-btn>
</div>
<!-- Card Content -->
<v-card-text class="pb-4 pt-3">
<div class="d-flex align-center mb-2">
<ProfileAvatar
:member-id="member.member_id"
:member-name="displayName"
:first-name="member.first_name"
:last-name="member.last_name"
size="medium"
class="mr-3"
/>
<div class="flex-grow-1">
<h3 class="text-subtitle-1 font-weight-bold mb-1">
{{ displayName }}
</h3>
<div class="nationality-display">
<template v-if="nationalitiesArray.length > 0">
<div class="d-flex align-center flex-wrap">
<!-- Display all flags together -->
<div class="flags-container d-flex align-center me-2">
<CountryFlag
v-for="nationality in nationalitiesArray"
:key="nationality"
:country-code="nationality"
:show-name="false"
size="small"
class="flag-item"
/>
</div>
<!-- Display country names -->
<div class="country-names">
<span class="text-caption text-medium-emphasis">
{{ nationalitiesArray.map(n => getCountryName(n)).join(', ') }}
</span>
</div>
</div>
</template>
<template v-else>
<span class="text-caption text-medium-emphasis">
Unknown
</span>
</template>
</div>
</div>
</div>
<!-- Member Info - More Compact -->
<div class="member-info mb-2">
<div class="info-row mb-1" v-if="member.email">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-caption">{{ member.email }}</span>
</div>
<div class="info-row mb-1" v-if="member.phone">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-caption">{{ member.FormattedPhone || member.phone }}</span>
</div>
<div class="info-row mb-1" v-if="member.member_since">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
<span class="text-caption">Since {{ formatDate(member.member_since) }}</span>
</div>
</div>
<!-- Status Section - Reorganized -->
<div class="status-section">
<!-- Primary Status (Dues) -->
<div class="d-flex align-center justify-space-between mb-2">
<v-chip
:color="duesColor"
:variant="duesVariant"
size="small"
class="mr-1"
>
<v-icon start size="12">{{ duesIcon }}</v-icon>
{{ duesText }}
</v-chip>
<!-- Portal Status - Compact -->
<v-tooltip
:text="member.keycloak_id ? 'Portal Account Active' : 'No Portal Account'"
location="top"
>
<template #activator="{ props }">
<v-chip
v-bind="props"
:color="member.keycloak_id ? 'success' : 'grey'"
variant="tonal"
size="x-small"
class="ml-1"
>
<v-icon size="12">{{ member.keycloak_id ? 'mdi-account-check' : 'mdi-account-off' }}</v-icon>
</v-chip>
</template>
</v-tooltip>
</div>
<!-- Secondary Status (Due Dates) - Only show if relevant -->
<div v-if="isDuesComingDue || (member.payment_due_date && !isDuesComingDue && isOverdue)" class="d-flex">
<v-chip
v-if="isDuesComingDue"
color="orange"
variant="flat"
size="x-small"
>
<v-icon start size="10">mdi-clock-alert</v-icon>
Due {{ formatDate(nextDuesDate) }}
</v-chip>
<v-chip
v-else-if="member.payment_due_date && !isDuesComingDue && isOverdue"
color="error"
variant="flat"
size="x-small"
>
<v-icon start size="10">mdi-calendar-alert</v-icon>
Overdue
</v-chip>
</div>
</div>
</v-card-text>
<!-- Click overlay for better UX -->
<div class="member-card-overlay" @click="$emit('view', member)"></div>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { getCountryName } from '~/utils/countries';
import {
isPaymentOverOneYear as checkPaymentOverOneYear,
isDuesActuallyCurrent as checkDuesActuallyCurrent,
calculateOverdueDays
} from '~/utils/dues-calculations';
interface Props {
member: Member;
canEdit?: boolean;
canDelete?: boolean;
canCreatePortalAccount?: boolean;
creatingPortalAccount?: boolean;
}
interface Emits {
(e: 'view', member: Member): void;
(e: 'edit', member: Member): void;
(e: 'delete', member: Member): void;
(e: 'create-portal-account', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canDelete: false,
canCreatePortalAccount: false,
creatingPortalAccount: false
});
defineEmits<Emits>();
// Computed properties
const memberInitials = computed(() => {
const firstName = props.member.first_name || '';
const lastName = props.member.last_name || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
});
const displayName = computed(() => {
// Try FullName first, then build from first_name + last_name, then fallback
return props.member.FullName ||
`${props.member.first_name || ''} ${props.member.last_name || ''}`.trim() ||
'New Member';
});
const avatarColor = computed(() => {
// Generate consistent color based on member ID using high-contrast colors
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink', 'brown'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
const nationalitiesArray = computed(() => {
if (!props.member.nationality) return [];
// Handle multiple nationalities separated by comma, semicolon, or pipe
const nationalities = props.member.nationality
.split(/[,;|]/)
.map(n => n.trim().toUpperCase())
.filter(n => n.length > 0);
return nationalities;
});
const isActive = computed(() => {
return props.member.membership_status === 'Active';
});
const statusColor = computed(() => {
const status = props.member.membership_status;
switch (status) {
case 'Active': return 'success';
case 'Inactive': return 'grey';
case 'Pending': return 'warning';
case 'Expired': return 'error';
default: return 'grey';
}
});
/**
* Check if a member is in their grace period
* Uses the same logic as dues-status API
*/
const isInGracePeriod = computed(() => {
if (!props.member.payment_due_date) return false;
try {
const dueDate = new Date(props.member.payment_due_date);
const today = new Date();
return dueDate > today;
} catch {
return false;
}
});
/**
* Check if a member's last payment is over 1 year old
* Uses standardized dues calculation function
*/
const isPaymentOverOneYear = computed(() => {
return checkPaymentOverOneYear(props.member);
});
/**
* Check if dues are actually current
* Uses standardized dues calculation function
*/
const isDuesActuallyCurrent = computed(() => {
return checkDuesActuallyCurrent(props.member);
});
const duesColor = computed(() => {
if (isDuesActuallyCurrent.value) return 'success';
if (isInGracePeriod.value) return 'warning';
return 'error';
});
const duesVariant = computed(() => {
if (isDuesActuallyCurrent.value) return 'tonal';
if (isInGracePeriod.value) return 'tonal';
return 'flat';
});
const duesIcon = computed(() => {
if (isDuesActuallyCurrent.value) return 'mdi-check-circle';
if (isInGracePeriod.value) return 'mdi-clock-alert';
return 'mdi-alert-circle';
});
const duesText = computed(() => {
if (isDuesActuallyCurrent.value) return 'Dues Paid';
if (isInGracePeriod.value) return 'Grace Period';
return 'Dues Outstanding';
});
const isOverdue = computed(() => {
// If dues are current, not overdue
if (isDuesActuallyCurrent.value) return false;
// If in grace period, not yet overdue
if (isInGracePeriod.value) return false;
// Check if payment_due_date has passed
if (props.member.payment_due_date) {
const dueDate = new Date(props.member.payment_due_date);
const today = new Date();
return dueDate < today;
}
// If no due date but not paid and not in grace period, consider overdue
return props.member.current_year_dues_paid !== 'true';
});
// Calculate next dues date (1 year from when they last paid)
const nextDuesDate = computed(() => {
// If dues are paid, calculate 1 year from payment date
if (props.member.current_year_dues_paid === 'true' && props.member.membership_date_paid) {
const lastPaidDate = new Date(props.member.membership_date_paid);
const nextDue = new Date(lastPaidDate);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue.toISOString().split('T')[0]; // Return as date string
}
// If not paid but has a due date, use that
if (props.member.payment_due_date) {
return props.member.payment_due_date;
}
// Fallback: 1 year from member since date
if (props.member.member_since) {
const memberSince = new Date(props.member.member_since);
const nextDue = new Date(memberSince);
nextDue.setFullYear(nextDue.getFullYear() + 1);
return nextDue.toISOString().split('T')[0];
}
return '';
});
// Check if dues are coming due within 2 months
const isDuesComingDue = computed(() => {
// Only show warning if dues are currently paid
if (props.member.current_year_dues_paid !== 'true') return false;
if (!nextDuesDate.value) return false;
const today = new Date();
const dueDate = new Date(nextDuesDate.value);
const twoMonthsFromNow = new Date();
twoMonthsFromNow.setMonth(twoMonthsFromNow.getMonth() + 2);
// Show warning if due date is within the next 2 months
return dueDate <= twoMonthsFromNow && dueDate > today;
});
// Email functionality
const emailLoading = ref(false);
const shouldShowEmailButton = computed(() => {
// Only show email button if member has email and is overdue or dues coming due
return !!(props.member.email && (isOverdue.value || isDuesComingDue.value));
});
// Methods
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
};
const sendDuesReminder = async () => {
if (!props.member.email || emailLoading.value) return;
emailLoading.value = true;
try {
// Determine the reminder type based on the member's status
const reminderType = isOverdue.value ? 'overdue' : 'due-soon';
const response = await $fetch<{
success: boolean;
message: string;
data: any;
}>(`/api/members/${props.member.Id}/send-dues-reminder`, {
method: 'post',
body: {
reminderType
}
});
if (response?.success) {
console.log(`Dues reminder sent successfully to ${props.member.email}`);
// You could show a success toast here if needed
}
} catch (error: any) {
console.error('Error sending dues reminder:', error);
// You could show an error toast here if needed
} finally {
emailLoading.value = false;
}
};
</script>
<style scoped>
.member-card {
cursor: pointer;
border-radius: 12px !important;
transition: all 0.3s ease;
position: relative;
height: 100%;
overflow: hidden;
}
.member-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15) !important;
}
.member-card--inactive {
opacity: 0.8;
}
.member-card--inactive .v-card-text {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.member-status-badge {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
}
.member-action-buttons {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 3;
display: flex;
gap: 4px;
}
.member-action-buttons .v-btn {
pointer-events: all;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
}
.nationality-display {
min-height: 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flags-container {
display: flex;
align-items: center;
}
.flag-item {
margin-right: 4px;
}
.flag-item:last-child {
margin-right: 0;
}
.country-names {
flex: 1;
}
.member-info {
min-height: 60px;
}
.info-row {
display: flex;
align-items: center;
min-height: 24px;
}
.dues-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
pointer-events: none;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.member-card {
margin-bottom: 16px;
}
.dues-status {
flex-direction: column;
align-items: flex-start;
}
.member-action-buttons {
bottom: 8px;
right: 8px;
}
}
/* Animation for status changes */
.v-chip {
transition: all 0.2s ease;
}
/* Custom scrollbar for long content */
.member-info::-webkit-scrollbar {
width: 4px;
}
.member-info::-webkit-scrollbar-track {
background: transparent;
}
.member-info::-webkit-scrollbar-thumb {
background-color: rgba(163, 21, 21, 0.3);
border-radius: 2px;
}
.text-error {
color: rgb(var(--v-theme-error)) !important;
}
/* Status Stripe Styles */
.status-stripe {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
z-index: 2;
border-radius: 12px 0 0 12px;
}
.status-stripe--overdue {
background: linear-gradient(180deg, #f44336 0%, #d32f2f 100%);
box-shadow: 2px 0 8px rgba(244, 67, 54, 0.3);
}
.status-stripe--due-soon {
background: linear-gradient(180deg, #ff9800 0%, #f57c00 100%);
box-shadow: 2px 0 8px rgba(255, 152, 0, 0.3);
}
.member-card--overdue {
border-left: 4px solid #f44336;
}
.member-card--due-soon {
border-left: 4px solid #ff9800;
}
</style>

View File

@ -0,0 +1,159 @@
<template>
<div class="monaco-logo" :class="sizeClass">
<v-img
:src="logoSrc"
:width="logoWidth"
:height="logoHeight"
class="logo-img"
alt="MonacoUSA Logo"
:style="logoStyle"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'white' | 'dark'
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
variant: 'default',
clickable: false
});
const emit = defineEmits<{
click: []
}>();
// Computed properties for responsive sizing
const sizeClass = computed(() => `monaco-logo--${props.size}`);
const logoSrc = computed(() => {
// Use the high-res Monaco flag image
return '/MONACOUSA-Flags_376x376.png';
});
const logoWidth = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoHeight = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoStyle = computed(() => {
const baseStyle: Record<string, string> = {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
};
if (props.clickable) {
baseStyle.cursor = 'pointer';
baseStyle.transition = 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out';
}
if (props.variant === 'white') {
baseStyle.backgroundColor = 'white';
baseStyle.padding = '4px';
} else if (props.variant === 'dark') {
baseStyle.backgroundColor = 'rgba(0, 0, 0, 0.1)';
baseStyle.padding = '4px';
}
return baseStyle;
});
// Handle click events
const handleClick = () => {
if (props.clickable) {
emit('click');
}
};
</script>
<style scoped>
.monaco-logo {
display: inline-flex;
align-items: center;
justify-content: center;
}
.monaco-logo--small {
min-width: 32px;
min-height: 32px;
}
.monaco-logo--medium {
min-width: 48px;
min-height: 48px;
}
.monaco-logo--large {
min-width: 80px;
min-height: 80px;
}
.logo-img {
object-fit: cover;
border-radius: inherit;
}
.monaco-logo:hover .logo-img {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(163, 21, 21, 0.2);
}
/* Ensure the logo maintains aspect ratio */
.v-img {
flex-shrink: 0;
}
/* Animation for clickable logos */
.monaco-logo[style*="cursor: pointer"]:hover {
transform: translateY(-2px);
}
.monaco-logo[style*="cursor: pointer"]:active {
transform: translateY(0);
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.logo-img,
.monaco-logo {
transition: none !important;
transform: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.logo-img {
border: 2px solid currentColor;
}
}
/* Print styles */
@media print {
.monaco-logo {
box-shadow: none !important;
}
.logo-img {
transform: none !important;
}
}
</style>

View File

@ -0,0 +1,175 @@
<template>
<span class="multiple-country-flags" :class="{ 'multiple-country-flags--small': size === 'small' }">
<ClientOnly>
<template v-if="countryCodes.length > 0">
<VueCountryFlag
v-for="(code, index) in countryCodes"
:key="`${code}-${index}`"
:country="code"
:size="flagSize"
:title="getCountryName(code)"
class="country-flag-item"
/>
</template>
<template v-else>
<span class="no-nationality">{{ fallbackText }}</span>
</template>
<template #fallback>
<span class="flag-placeholder" :style="placeholderStyle">🏳</span>
</template>
</ClientOnly>
<span v-if="showName && countryCodes.length > 0" class="country-names">
{{ countryNames }}
</span>
</span>
</template>
<script setup lang="ts">
import VueCountryFlag from 'vue-country-flag-next';
import { getCountryName, parseCountryInput } from '~/utils/countries';
interface Props {
nationality?: string; // Can be comma-separated like "FR,MC,US"
showName?: boolean;
size?: 'small' | 'medium' | 'large';
fallbackText?: string;
separator?: string; // For display names
}
const props = withDefaults(defineProps<Props>(), {
nationality: '',
showName: false,
size: 'medium',
fallbackText: 'Not specified',
separator: ', '
});
// Parse multiple nationalities
const countryCodes = computed(() => {
if (!props.nationality) return [];
// Split by comma and clean up
const codes = props.nationality
.split(',')
.map(code => code.trim())
.filter(code => code.length > 0)
.map(code => {
// If it's already a 2-letter code, use it
if (code.length === 2) {
return code.toUpperCase();
}
// Try to parse country name to get the code
return parseCountryInput(code) || '';
})
.filter(code => code.length === 2); // Only keep valid 2-letter codes
// Remove duplicates
return [...new Set(codes)];
});
const countryNames = computed(() => {
return countryCodes.value
.map(code => getCountryName(code))
.filter(name => name)
.join(props.separator);
});
const flagSize = computed(() => {
const sizeMap = {
small: 'sm',
medium: 'md',
large: 'lg'
};
return sizeMap[props.size];
});
const placeholderStyle = computed(() => {
const sizeMap = {
small: '1rem',
medium: '1.5rem',
large: '2rem'
};
return {
width: sizeMap[props.size],
height: `calc(${sizeMap[props.size]} * 0.75)`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
backgroundColor: '#f5f5f5',
fontSize: '0.75rem'
};
});
</script>
<style scoped>
.multiple-country-flags {
display: inline-flex;
align-items: center;
gap: 0.5rem;
vertical-align: middle;
}
.multiple-country-flags--small {
gap: 0.25rem;
}
.country-flag-item {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Add slight overlap for multiple flags to save space */
.country-flag-item:not(:first-child) {
margin-left: -0.25rem;
}
.multiple-country-flags--small .country-flag-item:not(:first-child) {
margin-left: -0.125rem;
}
.country-names {
font-size: 0.875rem;
color: inherit;
white-space: nowrap;
margin-left: 0.25rem;
}
.multiple-country-flags--small .country-names {
font-size: 0.75rem;
}
.no-nationality {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
font-style: italic;
}
.multiple-country-flags--small .no-nationality {
font-size: 0.75rem;
}
.flag-placeholder {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* Ensure proper flag display */
:deep(.vue-country-flag) {
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
position: relative;
z-index: 1;
}
/* Add hover effect to see all flags clearly */
.multiple-country-flags:hover .country-flag-item:not(:first-child) {
margin-left: 0.125rem;
transition: margin-left 0.2s ease;
}
</style>

View File

@ -0,0 +1,742 @@
<template>
<div class="multiple-nationality-input">
<div class="nationality-list">
<div
v-for="(nationality, index) in nationalities"
:key="`nationality-${index}`"
class="nationality-item d-flex align-center gap-2 mb-2"
>
<!-- Mobile Safari optimized country selector -->
<v-text-field
v-if="useMobileInterface"
:model-value="getSelectedCountryName(nationalities[index])"
:label="index === 0 && label ? label : `Nationality ${index + 1}`"
variant="outlined"
density="comfortable"
readonly
:error="hasError && index === 0"
:error-messages="hasError && index === 0 ? errorMessage : undefined"
@click="openMobileSelector(index)"
append-inner-icon="mdi-chevron-down"
class="nationality-select mobile-optimized"
>
<template #prepend-inner v-if="nationalities[index]">
<CountryFlag
:country-code="nationalities[index]"
:show-name="false"
size="small"
class="flag-icon me-2"
/>
</template>
</v-text-field>
<!-- Traditional v-select for desktop -->
<v-select
v-else
v-model="nationalities[index]"
:items="countryOptions"
:label="index === 0 && label ? label : `Nationality ${index + 1}`"
variant="outlined"
density="comfortable"
:error="hasError && index === 0"
:error-messages="hasError && index === 0 ? errorMessage : undefined"
@update:model-value="updateNationalities"
class="nationality-select"
>
<template #selection="{ item }">
<div class="flag-selection d-flex align-center">
<CountryFlag
:country-code="item.value"
:show-name="false"
size="small"
class="flag-icon me-2"
/>
<span class="country-name">{{ item.title }}</span>
</div>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" class="flag-list-item">
<template #prepend>
<div class="flag-prepend">
<CountryFlag
:country-code="item.raw.code"
:show-name="false"
size="small"
class="flag-icon"
/>
</div>
</template>
<v-list-item-title class="country-name">{{ item.raw.name }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="nationalities.length > 1"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeNationality(index)"
:title="`Remove ${getCountryName(nationality) || 'nationality'}`"
/>
</div>
</div>
<div class="nationality-actions mt-2">
<v-btn
variant="outlined"
color="primary"
size="small"
prepend-icon="mdi-plus"
@click="addNationality"
:disabled="disabled || nationalities.length >= maxNationalities"
>
Add Nationality
</v-btn>
<span v-if="nationalities.length >= maxNationalities" class="text-caption text-medium-emphasis ml-2">
Maximum {{ maxNationalities }} nationalities allowed
</span>
</div>
<!-- Preview of selected nationalities -->
<div v-if="nationalities.length > 0 && !hasEmptyNationality" class="nationality-preview mt-3">
<v-label class="text-caption mb-1">Selected Nationalities:</v-label>
<div class="d-flex flex-wrap gap-1">
<v-chip
v-for="nationality in validNationalities"
:key="nationality"
size="small"
variant="tonal"
color="primary"
>
<CountryFlag
:country-code="nationality"
:show-name="false"
size="small"
class="mr-1"
/>
{{ getCountryName(nationality) }}
</v-chip>
</div>
</div>
<!-- Mobile Safari Country Selection Dialog -->
<v-dialog
v-model="showMobileSelector"
:fullscreen="useMobileInterface"
:max-width="useMobileInterface ? undefined : '500px'"
:transition="useMobileInterface ? 'dialog-bottom-transition' : 'dialog-transition'"
class="mobile-country-dialog"
>
<v-card class="mobile-country-selector">
<v-card-title class="d-flex align-center justify-space-between pa-4">
<span class="text-h6">Select Country</span>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="showMobileSelector = false"
/>
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<!-- Search field -->
<div class="search-container pa-4 pb-2">
<v-text-field
v-model="searchQuery"
placeholder="Search countries..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
hide-details
clearable
class="country-search"
/>
</div>
<!-- Country list -->
<v-list class="country-list" density="comfortable">
<template v-for="country in filteredCountries" :key="country.code">
<v-list-item
@click="selectCountry(country.code)"
class="country-list-item"
:class="{ 'selected': nationalities[currentEditingIndex] === country.code }"
>
<template #prepend>
<div class="country-flag-container">
<CountryFlag
:country-code="country.code"
:show-name="false"
size="small"
class="country-flag"
/>
</div>
</template>
<v-list-item-title class="country-title">
{{ country.name }}
</v-list-item-title>
<template #append v-if="nationalities[currentEditingIndex] === country.code">
<v-icon color="primary" size="small">mdi-check</v-icon>
</template>
</v-list-item>
</template>
<v-list-item v-if="filteredCountries.length === 0" class="no-results">
<v-list-item-title class="text-center text-medium-emphasis">
No countries found
</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
variant="text"
@click="showMobileSelector = false"
class="text-none"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { getAllCountries, searchCountries } from '~/utils/countries';
// Simple device detection utilities
const detectMobile = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent;
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
};
const detectMobileSafari = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent;
return /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
};
interface Props {
modelValue?: string; // Comma-separated string like "FR,MC,US"
label?: string;
error?: boolean;
errorMessage?: string;
disabled?: boolean;
maxNationalities?: number;
required?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: string): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxNationalities: 5,
error: false,
disabled: false,
required: false
});
const emit = defineEmits<Emits>();
// Device detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
const needsPerformanceMode = ref(false);
// Initialize device detection on mount
onMounted(() => {
if (process.client) {
isMobile.value = detectMobile();
isMobileSafari.value = detectMobileSafari();
needsPerformanceMode.value = isMobileSafari.value || isMobile.value;
}
});
// Parse initial nationalities from comma-separated string
const parseNationalities = (value: string): string[] => {
if (!value || value.trim() === '') return [''];
return value.split(',').map(n => n.trim()).filter(n => n.length > 0);
};
// Reactive nationalities array
const nationalities = ref<string[]>(parseNationalities(props.modelValue));
// Ensure there's always at least one empty nationality field
if (nationalities.value.length === 0) {
nationalities.value = [''];
}
// Mobile optimization flags
const useMobileInterface = computed(() => isMobileSafari.value || needsPerformanceMode.value);
// Mobile dialog state
const showMobileSelector = ref(false);
const currentEditingIndex = ref(-1);
const searchQuery = ref('');
// Filtered countries for mobile selector
const filteredCountries = computed(() => {
const countries = getAllCountries();
if (!searchQuery.value) return countries;
const query = searchQuery.value.toLowerCase();
return countries.filter(country =>
country.name.toLowerCase().includes(query) ||
country.code.toLowerCase().includes(query)
);
});
// Watch for external model changes
watch(() => props.modelValue, (newValue) => {
const newNationalities = parseNationalities(newValue || '');
if (newNationalities.length === 0) newNationalities.push('');
// Only update if different to prevent loops
const current = nationalities.value.filter(n => n).join(',');
const incoming = newNationalities.filter(n => n).join(',');
if (current !== incoming) {
nationalities.value = newNationalities;
}
});
// Country options for dropdowns
const countryOptions = computed(() => {
const countries = getAllCountries();
return countries.map(country => ({
title: country.name,
value: country.code,
code: country.code,
name: country.name
}));
});
// Computed properties
const validNationalities = computed(() => {
return nationalities.value.filter(n => n && n.trim().length > 0);
});
const hasEmptyNationality = computed(() => {
return nationalities.value.some(n => !n || n.trim() === '');
});
const hasError = computed(() => {
return props.error || !!props.errorMessage;
});
// Methods
const addNationality = () => {
if (nationalities.value.length < props.maxNationalities) {
nationalities.value.push('');
}
};
const removeNationality = (index: number) => {
if (nationalities.value.length > 1) {
nationalities.value.splice(index, 1);
updateNationalities();
}
};
const updateNationalities = () => {
// Remove duplicates and empty values for the model
const uniqueValid = [...new Set(validNationalities.value)];
const result = uniqueValid.join(',');
emit('update:modelValue', result);
};
// Helper methods
const getCountryName = (countryCode: string): string => {
if (!countryCode) return '';
const countries = getAllCountries();
const country = countries.find(c => c.code === countryCode);
return country?.name || '';
};
// Mobile Safari specific methods
const getSelectedCountryName = (countryCode: string): string => {
if (!countryCode) return '';
return getCountryName(countryCode) || '';
};
const openMobileSelector = (index: number) => {
currentEditingIndex.value = index;
showMobileSelector.value = true;
};
const selectCountry = (countryCode: string) => {
if (currentEditingIndex.value >= 0) {
nationalities.value[currentEditingIndex.value] = countryCode;
updateNationalities();
}
showMobileSelector.value = false;
currentEditingIndex.value = -1;
};
// Watch nationalities array for changes
watch(nationalities, () => {
updateNationalities();
}, { deep: true });
// Initialize the model value on mount if needed
onMounted(() => {
if (!props.modelValue && validNationalities.value.length > 0) {
updateNationalities();
}
});
</script>
<style scoped>
.multiple-nationality-input {
width: 100%;
}
.nationality-item {
position: relative;
}
.nationality-item .v-select {
flex: 1;
}
.nationality-actions {
display: flex;
align-items: center;
gap: 8px;
}
.nationality-preview {
padding: 12px;
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
border-left: 4px solid rgb(var(--v-theme-primary));
}
.nationality-preview .v-chip {
margin: 2px;
}
/* Animation for adding/removing items */
.nationality-item {
transition: all 0.3s ease;
}
.nationality-item.v-enter-active,
.nationality-item.v-leave-active {
transition: all 0.3s ease;
}
.nationality-item.v-enter-from,
.nationality-item.v-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Error styling */
.error-message {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Focus and hover states */
.nationality-item .v-btn:hover {
background-color: rgba(var(--v-theme-error), 0.08);
}
/* Responsive adjustments */
@media (max-width: 600px) {
.nationality-item {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.nationality-item .v-btn {
align-self: flex-end;
width: fit-content;
}
}
/* Enhanced nationality select styling */
.nationality-select {
min-height: 56px;
}
/* Flag alignment fixes */
.flag-selection {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 24px;
padding: 2px 0;
}
.flag-prepend {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
margin-right: 12px;
flex-shrink: 0;
}
.flag-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
flex-shrink: 0;
width: 20px;
height: 15px;
}
.country-name {
line-height: 1.4;
font-size: 0.875rem;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.flag-list-item {
min-height: 48px;
padding: 8px 16px;
}
/* Vuetify overrides for better styling */
:deep(.nationality-select .v-field) {
min-height: 56px;
}
:deep(.nationality-select .v-field__input) {
align-items: center;
padding: 14px 16px;
min-height: 24px;
}
:deep(.nationality-select .v-field__field) {
align-items: center;
}
:deep(.nationality-select .v-field__overlay) {
border-radius: 8px;
}
:deep(.flag-list-item .v-list-item__prepend) {
align-self: center;
margin-inline-end: 12px;
}
:deep(.flag-selection) {
padding: 0;
margin: 0;
}
:deep(.v-select__selection) {
align-items: center;
}
/* Better dropdown menu styling */
:deep(.v-overlay__content .v-list) {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:deep(.v-list-item:hover) {
background-color: rgba(var(--v-theme-primary), 0.08);
}
:deep(.v-list-item--active) {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
/* Priority countries styling in dropdowns */
:deep(.v-list-item[data-country="MC"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="FR"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="US"]) {
background-color: rgba(var(--v-theme-primary), 0.02);
}
/* Mobile Safari Country Dialog Styles */
.mobile-country-dialog .v-dialog {
margin: 0;
}
.mobile-country-selector {
height: 100%;
display: flex;
flex-direction: column;
max-height: 100vh;
overflow: hidden;
}
.mobile-country-selector .v-card-text {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-container {
flex-shrink: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.country-list {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
max-height: calc(100vh - 200px);
}
.country-list-item {
min-height: 56px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.country-list-item:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.country-list-item.selected {
background-color: rgba(var(--v-theme-primary), 0.12);
}
.country-flag-container {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
min-width: 32px;
height: 24px;
margin-right: 16px;
flex-shrink: 0;
}
.country-flag {
width: 24px;
height: 18px;
}
.country-title {
font-size: 1rem;
line-height: 1.5;
color: rgba(var(--v-theme-on-surface), 0.87);
}
.no-results {
padding: 32px 16px;
}
/* Mobile optimized text field */
.nationality-select.mobile-optimized {
cursor: pointer;
}
.nationality-select.mobile-optimized :deep(.v-field__input) {
cursor: pointer;
}
.nationality-select.mobile-optimized :deep(.v-field__field) {
cursor: pointer;
}
/* Mobile Safari specific fixes */
@media (max-width: 768px) {
.mobile-country-dialog :deep(.v-overlay__content) {
margin: 0 !important;
max-height: none !important;
height: 100% !important;
width: 100% !important;
}
.mobile-country-selector {
border-radius: 0 !important;
}
.country-list {
max-height: calc(100vh - 160px);
}
.country-list-item {
min-height: 60px; /* Larger touch targets */
padding: 16px;
}
.country-flag-container {
width: 36px;
min-width: 36px;
height: 27px;
}
.country-flag {
width: 28px;
height: 21px;
}
}
/* Performance optimizations for mobile Safari */
.is-mobile-safari .mobile-country-selector,
.performance-mode .mobile-country-selector {
-webkit-transform: translateZ(0); /* Force hardware acceleration */
transform: translateZ(0);
}
.is-mobile-safari .country-list,
.performance-mode .country-list {
will-change: scroll-position;
}
.is-mobile-safari .country-list-item,
.performance-mode .country-list-item {
transition: none; /* Disable transitions for better performance */
}
/* Smooth scrolling fix for mobile Safari */
.mobile-country-dialog :deep(.v-overlay__scrim) {
background: rgba(0, 0, 0, 0.5);
}
/* Fix dialog transition on mobile */
@media (max-width: 768px) {
.mobile-country-dialog :deep(.v-dialog-transition-enter-active),
.mobile-country-dialog :deep(.v-dialog-transition-leave-active) {
transition: transform 0.3s ease-out;
}
.mobile-country-dialog :deep(.v-dialog-transition-enter-from) {
transform: translateY(100%);
}
.mobile-country-dialog :deep(.v-dialog-transition-leave-to) {
transform: translateY(100%);
}
}
</style>

View File

@ -0,0 +1,474 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="700"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-database-cog</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
NocoDB Configuration
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>Admin Only Configuration</template>
Configure the NocoDB database connection for the Member Management system.
These settings will override environment variables when set.
</v-alert>
<v-form ref="formRef" v-model="formValid">
<v-row>
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Database Connection</h3>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.url"
label="NocoDB URL"
variant="outlined"
:rules="[rules.required, rules.url]"
required
placeholder="https://database.monacousa.org"
:error="hasFieldError('url')"
:error-messages="getFieldError('url')"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.apiKey"
label="API Token"
variant="outlined"
:rules="[rules.required]"
required
:type="showApiKey ? 'text' : 'password'"
:append-inner-icon="showApiKey ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showApiKey = !showApiKey"
placeholder="Enter your NocoDB API token"
:error="hasFieldError('apiKey')"
:error-messages="getFieldError('apiKey')"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.baseId"
label="Base ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="your-base-id"
:error="hasFieldError('baseId')"
:error-messages="getFieldError('baseId')"
/>
</v-col>
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Table Configuration</h3>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.members"
label="Members Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="members-table-id"
:error="hasFieldError('tables.members')"
:error-messages="getFieldError('tables.members')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Members functionality
</div>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.events"
label="Events Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="events-table-id"
:error="hasFieldError('tables.events')"
:error-messages="getFieldError('tables.events')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Events functionality
</div>
</v-col>
<v-col cols="12">
<v-text-field
v-model="form.tables.rsvps"
label="RSVPs Table ID"
variant="outlined"
:rules="[rules.required]"
required
placeholder="rsvps-table-id"
:error="hasFieldError('tables.rsvps')"
:error-messages="getFieldError('tables.rsvps')"
/>
<div class="text-caption text-medium-emphasis mt-1">
Configure the table ID for the Event RSVPs functionality
</div>
</v-col>
<v-col cols="12">
<v-divider class="my-2" />
</v-col>
<!-- Connection Status -->
<v-col cols="12" md="6">
<v-btn
@click="testConnection"
:loading="testLoading"
:disabled="!formValid || loading"
color="info"
variant="outlined"
block
>
<v-icon start>mdi-database-check</v-icon>
Test Connection
</v-btn>
</v-col>
<v-col cols="12" md="6">
<div class="d-flex align-center h-100">
<v-chip
v-if="connectionStatus"
:color="connectionStatus.success ? 'success' : 'error'"
variant="flat"
size="small"
>
<v-icon start size="14">
{{ connectionStatus.success ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ connectionStatus.message }}
</v-chip>
<span v-else class="text-caption text-medium-emphasis">
Connection not tested
</span>
</div>
</v-col>
<!-- Display errors -->
<v-col cols="12" v-if="hasGeneralError">
<v-alert
type="error"
variant="tonal"
closable
@click:close="clearGeneralError"
>
{{ getGeneralError }}
</v-alert>
</v-col>
<!-- Display success -->
<v-col cols="12" v-if="showSuccessMessage">
<v-alert
type="success"
variant="tonal"
closable
@click:close="showSuccessMessage = false"
>
NocoDB configuration saved successfully!
</v-alert>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="saveSettings"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-content-save</v-icon>
Save Configuration
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { NocoDBSettings } from '~/utils/types';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'settings-saved'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
const testLoading = ref(false);
const showApiKey = ref(false);
const showSuccessMessage = ref(false);
// Form data
const form = ref<NocoDBSettings>({
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: {
members: '',
events: '',
rsvps: ''
}
});
// Error handling
const fieldErrors = ref<Record<string, string>>({});
const connectionStatus = ref<{ success: boolean; message: string } | null>(null);
// Validation rules
const rules = {
required: (value: string) => {
return !!value?.trim() || 'This field is required';
},
url: (value: string) => {
if (!value) return true; // Let required rule handle empty values
const pattern = /^https?:\/\/.+/;
return pattern.test(value) || 'Please enter a valid URL';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const hasGeneralError = computed(() => {
return !!fieldErrors.value.general;
});
const getGeneralError = computed(() => {
return fieldErrors.value.general || '';
});
const clearFieldErrors = () => {
fieldErrors.value = {};
};
const clearGeneralError = () => {
delete fieldErrors.value.general;
};
// Load current settings
const loadSettings = async () => {
try {
const response = await $fetch<{ success: boolean; data?: NocoDBSettings }>('/api/admin/nocodb-config');
if (response.success && response.data) {
form.value = { ...response.data };
// Ensure tables object exists with all required fields
if (!form.value.tables) {
form.value.tables = {
members: '',
events: '',
rsvps: ''
};
}
}
} catch (error: any) {
console.error('Failed to load NocoDB settings:', error);
// Use defaults if loading fails
}
};
// Test connection
const testConnection = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
testLoading.value = true;
connectionStatus.value = null;
try {
const response = await $fetch<{ success: boolean; message: string }>('/api/admin/nocodb-test', {
method: 'POST',
body: form.value
});
connectionStatus.value = {
success: response.success,
message: response.message || (response.success ? 'Connection successful' : 'Connection failed')
};
} catch (error: any) {
connectionStatus.value = {
success: false,
message: error.message || 'Connection test failed'
};
} finally {
testLoading.value = false;
}
};
// Save settings
const saveSettings = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
const response = await $fetch<{ success: boolean; message?: string }>('/api/admin/nocodb-config', {
method: 'POST',
body: form.value
});
if (response.success) {
showSuccessMessage.value = true;
emit('settings-saved');
// Auto-close after a delay
setTimeout(() => {
closeDialog();
}, 2000);
} else {
throw new Error(response.message || 'Failed to save settings');
}
} catch (error: any) {
console.error('Error saving NocoDB settings:', error);
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
fieldErrors.value.general = error.message || 'Failed to save NocoDB configuration. Please try again.';
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForm = () => {
form.value = {
url: 'https://database.monacousa.org',
apiKey: '',
baseId: '',
tables: {
members: '',
events: '',
rsvps: ''
}
};
clearFieldErrors();
connectionStatus.value = null;
showSuccessMessage.value = false;
nextTick(() => {
formRef.value?.resetValidation();
});
};
// Watch for dialog open
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
resetForm();
await loadSettings();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section styling */
.v-card-text .v-row .v-col h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Connection status styling */
.h-100 {
height: 100%;
}
/* Password field styling */
.v-text-field :deep(.v-input__append-inner) {
cursor: pointer;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<v-card
v-if="showBanner"
class="pwa-install-banner"
elevation="8"
variant="elevated"
>
<v-card-text class="pa-4">
<v-row align="center" no-gutters>
<v-col cols="auto" class="mr-3">
<v-avatar size="48" color="white">
<v-img src="/icon-192x192.png" alt="MonacoUSA Portal" />
</v-avatar>
</v-col>
<v-col>
<div class="text-white">
<div class="text-subtitle-1 font-weight-bold mb-1">
Install MonacoUSA Portal
</div>
<div class="text-body-2 text-grey-lighten-2">
{{ installMessage }}
</div>
</div>
</v-col>
<v-col cols="auto">
<v-btn
v-if="canInstall"
@click="installPWA"
color="white"
variant="elevated"
size="small"
class="mr-2"
:loading="installing"
>
<v-icon start>mdi-download</v-icon>
Install
</v-btn>
<v-btn
@click="dismissBanner"
color="white"
variant="text"
size="small"
icon
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// Reactive state
const showBanner = ref(false);
const canInstall = ref(false);
const installing = ref(false);
const installMessage = ref('Add to your home screen for quick access');
let deferredPrompt: BeforeInstallPromptEvent | null = null;
// Device detection
const isIOS = computed(() => {
if (process.client) {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
return false;
});
const isAndroid = computed(() => {
if (process.client) {
return /Android/.test(navigator.userAgent);
}
return false;
});
const isStandalone = computed(() => {
if (process.client) {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
return false;
});
// Install messages based on platform
const getInstallMessage = () => {
if (isIOS.value) {
return 'Tap Share → Add to Home Screen to install';
} else if (isAndroid.value) {
return 'Add to your home screen for quick access';
} else {
return 'Install this app for a better experience';
}
};
// PWA installation logic
const installPWA = async () => {
if (!deferredPrompt) return;
installing.value = true;
try {
// Show the install prompt
await deferredPrompt.prompt();
// Wait for the user to respond to the prompt
const { outcome } = await deferredPrompt.userChoice;
console.log(`PWA install prompt outcome: ${outcome}`);
if (outcome === 'accepted') {
console.log('✅ PWA installation accepted');
showBanner.value = false;
localStorage.setItem('pwa-install-dismissed', 'true');
}
// Clear the deferredPrompt
deferredPrompt = null;
canInstall.value = false;
} catch (error) {
console.error('❌ PWA installation error:', error);
} finally {
installing.value = false;
}
};
const dismissBanner = () => {
showBanner.value = false;
localStorage.setItem('pwa-install-dismissed', 'true');
localStorage.setItem('pwa-install-dismissed-date', new Date().toISOString());
};
const shouldShowBanner = () => {
// Don't show if already dismissed recently (within 7 days)
const dismissedDate = localStorage.getItem('pwa-install-dismissed-date');
if (dismissedDate) {
const daysSinceDismissed = (Date.now() - new Date(dismissedDate).getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceDismissed < 7) {
return false;
}
}
// Don't show if permanently dismissed
if (localStorage.getItem('pwa-install-dismissed') === 'true' && !dismissedDate) {
return false;
}
// Don't show if already installed
if (isStandalone.value) {
return false;
}
return true;
};
// Setup event listeners
onMounted(() => {
if (!process.client) return;
installMessage.value = getInstallMessage();
// Listen for the beforeinstallprompt event
window.addEventListener('beforeinstallprompt', (e: Event) => {
console.log('🔔 PWA install prompt available');
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
// Save the event so it can be triggered later
deferredPrompt = e as BeforeInstallPromptEvent;
canInstall.value = true;
// Show banner if conditions are met
if (shouldShowBanner()) {
showBanner.value = true;
}
});
// Listen for successful installation
window.addEventListener('appinstalled', () => {
console.log('✅ PWA was installed successfully');
showBanner.value = false;
deferredPrompt = null;
canInstall.value = false;
});
// For iOS devices, show banner if not installed and not dismissed
if (isIOS.value && shouldShowBanner()) {
showBanner.value = true;
}
});
</script>
<style scoped>
.pwa-install-banner {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 1000;
max-width: 400px;
margin: 0 auto;
border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3) !important;
background: #a31515 !important; /* Solid MonacoUSA red */
background-image: none !important; /* Remove any gradients */
}
@media (max-width: 600px) {
.pwa-install-banner {
left: 16px;
right: 16px;
bottom: 16px;
}
}
/* Animation */
.pwa-install-banner {
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,626 @@
<template>
<div class="phone-input-wrapper" :class="{ 'phone-input-wrapper--mobile': mobileDetection.isMobile }">
<v-text-field
v-model="localNumber"
:label="label"
:placeholder="placeholder"
:error="error"
:error-messages="errorMessage"
:hint="helpText"
:persistent-hint="!!helpText"
:required="required"
:disabled="disabled"
variant="outlined"
:density="mobileDetection.isMobile ? 'default' : 'comfortable'"
class="phone-text-field"
@input="handleInput"
@blur="handleBlur"
>
<template #prepend-inner>
<!-- Country Selector -->
<v-menu
v-model="dropdownOpen"
:close-on-content-click="false"
location="bottom start"
:offset="4"
:min-width="mobileDetection.isMobile ? '90vw' : '280'"
:transition="mobileDetection.isMobile ? 'none' : 'fade-transition'"
:no-click-animation="true"
:persistent="mobileDetection.isMobile"
:attach="false"
>
<template #activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="country-selector"
:class="{
'country-selector--open': dropdownOpen,
'country-selector--mobile': mobileDetection.isMobile
}"
>
<img
:src="flagUrl"
:alt="`${selectedCountry.name} flag`"
class="country-flag"
@error="handleFlagError"
/>
<span class="country-code">{{ selectedCountry.dialCode }}</span>
<v-icon
:size="mobileDetection.isMobile ? 18 : 16"
class="dropdown-icon"
:class="{ 'dropdown-icon--rotated': dropdownOpen }"
>
mdi-chevron-down
</v-icon>
</div>
</template>
<!-- Dropdown Content -->
<v-card
class="country-dropdown"
:class="{ 'country-dropdown--mobile': mobileDetection.isMobile }"
:elevation="mobileDetection.isMobile ? 24 : 8"
>
<!-- Mobile Header -->
<div v-if="mobileDetection.isMobile" class="mobile-header">
<h3 class="mobile-title">Select Country</h3>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="closeDropdown"
class="close-btn"
/>
</div>
<!-- Search Bar -->
<div class="search-container">
<v-text-field
v-model="searchQuery"
placeholder="Search countries..."
variant="outlined"
:density="mobileDetection.isMobile ? 'default' : 'compact'"
prepend-inner-icon="mdi-magnify"
hide-details
class="search-input"
:autofocus="!mobileDetection.isMobile"
clearable
/>
</div>
<!-- Country List -->
<v-list
class="country-list"
:class="{ 'country-list--mobile': mobileDetection.isMobile }"
:density="mobileDetection.isMobile ? 'default' : 'compact'"
>
<v-list-item
v-for="country in filteredCountries"
:key="country.iso2"
:class="{
'country-item': true,
'country-item--selected': country.iso2 === selectedCountry.iso2,
'country-item--preferred': isPreferredCountry(country.iso2),
'country-item--mobile': mobileDetection.isMobile
}"
@click="selectCountry(country)"
:ripple="mobileDetection.isMobile"
>
<template #prepend>
<img
:src="getCountryFlagUrl(country.iso2)"
:alt="`${country.name} flag`"
class="list-flag"
:class="{ 'list-flag--mobile': mobileDetection.isMobile }"
@error="handleFlagError"
/>
</template>
<v-list-item-title
class="country-name"
:class="{ 'country-name--mobile': mobileDetection.isMobile }"
>
{{ country.name }}
</v-list-item-title>
<template #append>
<span
class="dial-code"
:class="{ 'dial-code--mobile': mobileDetection.isMobile }"
>
{{ country.dialCode }}
</span>
</template>
</v-list-item>
</v-list>
<!-- Mobile Footer -->
<div v-if="mobileDetection.isMobile" class="mobile-footer">
<v-btn
block
variant="text"
@click="closeDropdown"
class="cancel-btn"
>
Cancel
</v-btn>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script setup lang="ts">
import { parsePhoneNumber, AsYouType } from 'libphonenumber-js';
import { getPhoneCountriesWithPreferred, searchPhoneCountries, getPhoneCountryByCode, type PhoneCountry } from '~/utils/phone-countries';
interface Props {
modelValue?: string;
label?: string;
placeholder?: string;
error?: boolean;
errorMessage?: string;
helpText?: string;
required?: boolean;
disabled?: boolean;
defaultCountry?: string;
preferredCountries?: string[];
}
interface Emits {
(e: 'update:modelValue', value: string): void;
(e: 'country-changed', country: PhoneCountry): void;
(e: 'phone-data', data: { number: string; isValid: boolean; country: PhoneCountry }): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: 'Phone number',
error: false,
required: false,
disabled: false,
defaultCountry: 'MC',
preferredCountries: () => ['MC', 'FR', 'US', 'IT', 'CH']
});
const emit = defineEmits<Emits>();
// Simple mobile detection
const isMobile = ref(false);
const isMobileSafari = ref(false);
// Initialize mobile detection
onMounted(() => {
if (process.client) {
const userAgent = navigator.userAgent;
isMobile.value = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || window.innerWidth <= 768;
isMobileSafari.value = /iPhone|iPad|iPod/i.test(userAgent) && /Safari/i.test(userAgent);
}
});
// Create computed-like object for template compatibility
const mobileDetection = computed(() => ({
isMobile: isMobile.value,
isMobileSafari: isMobileSafari.value
}));
// Get comprehensive countries list
const countries = getPhoneCountriesWithPreferred(props.preferredCountries);
// Reactive state
const dropdownOpen = ref(false);
const searchQuery = ref('');
const localNumber = ref('');
const selectedCountry = ref<PhoneCountry>(
getPhoneCountryByCode(props.defaultCountry) || countries[0]
);
// Computed
const flagUrl = computed(() => getCountryFlagUrl(selectedCountry.value.iso2));
const filteredCountries = computed(() => {
return searchPhoneCountries(searchQuery.value, props.preferredCountries);
});
// Methods
const getCountryFlagUrl = (iso2: string) => {
return `https://flagcdn.com/24x18/${iso2.toLowerCase()}.png`;
};
const isPreferredCountry = (iso2: string) => {
return props.preferredCountries.includes(iso2);
};
const selectCountry = (country: PhoneCountry) => {
selectedCountry.value = country;
dropdownOpen.value = false;
searchQuery.value = ''; // Clear search on selection
emit('country-changed', country);
// Reformat existing number with new country
if (localNumber.value) {
handleInput();
}
};
const handleInput = () => {
const rawInput = localNumber.value;
// Create full international number
const fullNumber = selectedCountry.value.dialCode + rawInput.replace(/\D/g, '');
try {
// Parse and validate
const phoneNumber = parsePhoneNumber(fullNumber);
const isValid = phoneNumber?.isValid() || false;
// Format for display (national format)
if (phoneNumber && isValid) {
const formatter = new AsYouType(selectedCountry.value.iso2 as any);
const formatted = formatter.input(rawInput);
localNumber.value = formatted;
}
// Emit data
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid,
country: selectedCountry.value
});
} catch (error) {
// Handle invalid numbers gracefully
emit('update:modelValue', fullNumber);
emit('phone-data', {
number: fullNumber,
isValid: false,
country: selectedCountry.value
});
}
};
const handleBlur = () => {
// Additional formatting on blur if needed
};
const handleFlagError = (event: Event) => {
// Fallback to a default flag or hide image
const img = event.target as HTMLImageElement;
img.style.display = 'none';
};
// Mobile-specific handlers
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = '';
};
// Initialize from modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== selectedCountry.value.dialCode + localNumber.value.replace(/\D/g, '')) {
try {
const phoneNumber = parsePhoneNumber(newValue);
if (phoneNumber) {
// Find matching country
const matchingCountry = countries.find(c =>
c.dialCode === '+' + phoneNumber.countryCallingCode
);
if (matchingCountry) {
selectedCountry.value = matchingCountry;
}
// Set local number (national format)
localNumber.value = phoneNumber.formatNational().replace(phoneNumber.countryCallingCode, '').trim();
}
} catch (error) {
// Handle invalid initial value
localNumber.value = newValue;
}
}
}, { immediate: true });
// Clean up search query when dropdown closes
watch(dropdownOpen, (isOpen) => {
if (!isOpen) {
// Clear search after a small delay to allow selection to complete
setTimeout(() => {
searchQuery.value = '';
}, 100);
}
});
// Component initialization
onMounted(() => {
console.log('[PhoneInputWrapper] Initialized with device info:', {
isMobile: isMobile.value,
isMobileSafari: isMobileSafari.value
});
});
</script>
<style scoped>
.phone-input-wrapper {
width: 100%;
}
.country-selector {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(var(--v-theme-surface), 1);
border: 1px solid transparent;
margin-right: 8px;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.country-selector:hover {
background: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.24);
}
.country-selector--open {
background: rgba(var(--v-theme-primary), 0.12);
border-color: rgba(var(--v-theme-primary), 0.48);
}
.country-flag {
width: 24px;
height: 18px;
border-radius: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
object-fit: cover;
}
.country-code {
font-size: 0.875rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
min-width: 32px;
}
.dropdown-icon {
transition: transform 0.2s ease;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.dropdown-icon--rotated {
transform: rotate(180deg);
}
/* Dropdown Styling */
.country-dropdown {
min-width: 280px;
max-width: 320px;
max-height: 400px;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-container {
padding: 12px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.search-input :deep(.v-field) {
background: rgba(var(--v-theme-surface), 1);
}
/* Country List */
.country-list {
flex: 1;
max-height: 300px;
overflow-y: auto;
background: rgba(var(--v-theme-surface), 1);
-webkit-overflow-scrolling: touch;
}
.country-list::-webkit-scrollbar {
width: 6px;
}
.country-list::-webkit-scrollbar-track {
background: transparent;
}
.country-list::-webkit-scrollbar-thumb {
background: rgba(var(--v-theme-primary), 0.3);
border-radius: 3px;
}
.country-item {
cursor: pointer;
transition: all 0.15s ease;
border-left: 3px solid transparent;
}
.country-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
}
.country-item--selected {
background: rgba(var(--v-theme-primary), 0.12) !important;
border-left-color: rgb(var(--v-theme-primary));
font-weight: 600;
}
.country-item--preferred {
background: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
.list-flag {
width: 20px;
height: 15px;
border-radius: 2px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
object-fit: cover;
}
.country-name {
font-size: 0.875rem;
font-weight: 500;
}
.dial-code {
font-size: 0.8125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-family: 'Roboto Mono', monospace;
}
/* Mobile Header */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-primary), 0.04);
}
.mobile-title {
font-size: 1.125rem;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
margin: 0;
}
.close-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile Footer */
.mobile-footer {
padding: 16px 20px;
border-top: 1px solid rgba(var(--v-theme-outline), 0.12);
background: rgba(var(--v-theme-surface), 1);
}
.cancel-btn {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
}
/* Mobile-specific styling */
.phone-input-wrapper--mobile {
position: relative;
}
.country-selector--mobile {
padding: 6px 10px;
margin-right: 6px;
border-radius: 8px;
min-height: 44px; /* Touch-friendly size */
align-items: center;
-webkit-tap-highlight-color: transparent;
}
.country-selector--mobile:active {
background: rgba(var(--v-theme-primary), 0.16);
}
.country-dropdown--mobile {
width: 90vw !important;
max-width: 400px !important;
max-height: 70vh !important;
}
.country-list--mobile {
max-height: calc(50vh - 120px) !important;
-webkit-overflow-scrolling: touch;
}
.country-item--mobile {
min-height: 56px !important;
padding: 12px 20px !important;
border-left-width: 4px !important;
-webkit-tap-highlight-color: transparent;
}
.country-item--mobile:active {
background: rgba(var(--v-theme-primary), 0.16) !important;
}
.list-flag--mobile {
width: 24px !important;
height: 18px !important;
}
.country-name--mobile {
font-size: 1rem !important;
font-weight: 500 !important;
}
.dial-code--mobile {
font-size: 0.9375rem !important;
font-weight: 600 !important;
}
/* Touch-friendly input field */
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field) {
min-height: 56px !important;
}
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on iOS */
padding: 16px !important;
}
/* Responsive Breakpoints */
@media (max-width: 768px) {
.country-dropdown {
min-width: 260px;
max-width: 300px;
}
.country-list {
max-height: 250px;
}
.country-selector {
min-height: 48px;
padding: 6px 10px;
}
.search-input :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom */
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
.phone-input-wrapper--mobile .phone-text-field :deep(.v-field__input) {
font-size: 16px !important; /* Prevent zoom on focus */
-webkit-appearance: none;
}
.search-input :deep(.v-field__input) {
font-size: 16px !important;
-webkit-appearance: none;
}
.country-list {
-webkit-overflow-scrolling: touch;
}
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.country-item,
.country-selector,
.dropdown-icon {
transition: none !important;
}
}
</style>

View File

@ -0,0 +1,310 @@
<template>
<v-avatar
:size="avatarSize"
:color="showInitials ? backgroundColor : 'grey-lighten-2'"
:class="avatarClass"
>
<!-- Loading state -->
<v-progress-circular
v-if="loading"
:size="iconSize"
indeterminate
color="white"
/>
<!-- Profile image -->
<v-img
v-else-if="imageUrl && !imageError && !loading"
:src="imageUrl"
:alt="altText"
cover
@error="handleImageError"
@load="handleImageLoad"
:class="imageClass"
/>
<!-- Initials fallback -->
<span
v-else-if="initials && !loading"
:class="['text-white font-weight-bold', initialsClass]"
:style="{ fontSize: initialsSize }"
>
{{ initials }}
</span>
<!-- Icon fallback -->
<v-icon
v-else
:size="iconSize"
color="grey-darken-2"
>
mdi-account
</v-icon>
</v-avatar>
</template>
<script setup lang="ts">
import { generateInitials, generateAvatarColor } from '~/utils/client-utils';
interface Props {
memberId?: string;
memberName?: string;
firstName?: string;
lastName?: string;
size?: 'small' | 'medium' | 'large';
lazy?: boolean;
clickable?: boolean;
showBorder?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
lazy: true,
clickable: false,
showBorder: false
});
const emit = defineEmits<{
click: [];
imageLoaded: [];
imageError: [error: string];
}>();
// Reactive state
const loading = ref(false);
const imageError = ref(false);
const imageUrl = ref<string | null>(null);
const isVisible = ref(false);
// Computed properties
const avatarSize = computed(() => {
switch (props.size) {
case 'small': return 36;
case 'medium': return 80;
case 'large': return 200;
default: return 80;
}
});
const iconSize = computed(() => {
switch (props.size) {
case 'small': return 20;
case 'medium': return 40;
case 'large': return 100;
default: return 40;
}
});
const initialsSize = computed(() => {
switch (props.size) {
case 'small': return '14px';
case 'medium': return '28px';
case 'large': return '72px';
default: return '28px';
}
});
const initials = computed(() => {
if (props.firstName && props.lastName) {
return generateInitials(props.firstName, props.lastName);
}
if (props.memberName) {
return generateInitials(undefined, undefined, props.memberName);
}
return '?';
});
const backgroundColor = computed(() => {
const name = props.memberName || `${props.firstName} ${props.lastName}`.trim();
return name ? generateAvatarColor(name) : '#9e9e9e';
});
const showInitials = computed(() => {
return !loading.value && !imageUrl.value && initials.value !== '?';
});
const altText = computed(() => {
return props.memberName || `${props.firstName} ${props.lastName}`.trim() || 'Profile';
});
const avatarClass = computed(() => [
{
'cursor-pointer': props.clickable,
'elevation-2': props.showBorder,
'profile-avatar--border': props.showBorder
}
]);
const imageClass = computed(() => [
'profile-avatar__image',
{
'profile-avatar__image--loaded': !loading.value
}
]);
const initialsClass = computed(() => [
'profile-avatar__initials',
{
'text-h6': props.size === 'small',
'text-h4': props.size === 'medium',
'text-h1': props.size === 'large'
}
]);
// Methods
const loadProfileImage = async () => {
if (!props.memberId || loading.value) {
return;
}
try {
loading.value = true;
imageError.value = false;
const sizeParam = props.size === 'small' ? 'small' :
props.size === 'large' ? 'medium' : 'medium'; // Use medium for both medium and large
const response = await $fetch(`/api/profile/image/${props.memberId}/${sizeParam}`);
if (response.success && response.imageUrl) {
// Pre-load the image to ensure it's valid
const img = new Image();
img.onload = () => {
imageUrl.value = response.imageUrl;
loading.value = false;
emit('imageLoaded');
};
img.onerror = () => {
handleImageError();
};
img.src = response.imageUrl;
} else {
loading.value = false;
}
} catch (error: any) {
console.warn(`Profile image not found for member ${props.memberId}:`, error.message);
loading.value = false;
imageError.value = true;
}
};
const handleImageError = () => {
loading.value = false;
imageError.value = true;
imageUrl.value = null;
emit('imageError', 'Failed to load profile image');
};
const handleImageLoad = () => {
loading.value = false;
emit('imageLoaded');
};
const handleClick = () => {
if (props.clickable) {
emit('click');
}
};
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = null;
const avatarRef = ref<HTMLElement>();
const initIntersectionObserver = () => {
if (!props.lazy || !avatarRef.value || typeof IntersectionObserver === 'undefined') {
// Load immediately if not lazy or no intersection observer support
isVisible.value = true;
loadProfileImage();
return;
}
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && !isVisible.value) {
isVisible.value = true;
loadProfileImage();
// Stop observing once visible
if (observer && avatarRef.value) {
observer.unobserve(avatarRef.value);
}
}
},
{
rootMargin: '50px',
threshold: 0.1
}
);
observer.observe(avatarRef.value);
};
// Watch for prop changes
watch(
() => props.memberId,
(newMemberId) => {
if (newMemberId) {
imageUrl.value = null;
imageError.value = false;
if (isVisible.value || !props.lazy) {
loadProfileImage();
}
} else {
imageUrl.value = null;
imageError.value = false;
loading.value = false;
}
}
);
// Lifecycle
onMounted(() => {
if (props.memberId) {
if (props.lazy) {
nextTick(() => {
initIntersectionObserver();
});
} else {
loadProfileImage();
}
}
});
onUnmounted(() => {
if (observer && avatarRef.value) {
observer.unobserve(avatarRef.value);
observer = null;
}
});
</script>
<style scoped>
.profile-avatar--border {
border: 2px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.profile-avatar__image {
transition: opacity 0.3s ease-in-out;
opacity: 0;
}
.profile-avatar__image--loaded {
opacity: 1;
}
.profile-avatar__initials {
user-select: none;
letter-spacing: -0.5px;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-pointer:hover {
transform: scale(1.05);
transition: transform 0.2s ease-in-out;
}
</style>

View File

@ -0,0 +1,335 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="600"
persistent
scrollable
>
<v-card class="registration-success-card">
<v-card-title class="d-flex align-center pa-6 bg-success">
<v-icon class="mr-3 text-white" size="32">mdi-check-circle</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Registration Successful!
</h2>
</v-card-title>
<v-card-text class="pa-6">
<!-- Success Message -->
<div class="text-center mb-6">
<v-avatar size="80" class="mb-4" color="success">
<v-icon size="48" color="white">mdi-account-check</v-icon>
</v-avatar>
<h3 class="text-h6 mb-3">
Welcome to MonacoUSA Association!
</h3>
<p class="text-body-1 mb-2">
Your membership application has been submitted successfully.
</p>
<v-chip
v-if="memberData?.memberId"
color="success"
variant="outlined"
size="small"
class="ma-1"
>
<v-icon start size="14">mdi-identifier</v-icon>
Member ID: {{ memberData.memberId }}
</v-chip>
</div>
<v-divider class="mb-6" />
<!-- Next Steps -->
<div class="mb-6">
<h4 class="text-h6 mb-3 d-flex align-center">
<v-icon class="mr-2" color="primary">mdi-format-list-checks</v-icon>
Next Steps
</h4>
<v-timeline density="compact" side="end">
<v-timeline-item
dot-color="success"
size="small"
icon="mdi-check"
>
<template #opposite>
<strong class="text-body-2">Step 1</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Registration Complete</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Your account has been created in our system.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="warning"
size="small"
icon="mdi-email"
>
<template #opposite>
<strong class="text-body-2">Step 2</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Check Your Email</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
We've sent a verification email to <strong>{{ memberData?.email }}</strong>.
Click the link in the email to verify your account and set your password.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="info"
size="small"
icon="mdi-bank"
>
<template #opposite>
<strong class="text-body-2">Step 3</strong>
</template>
<div class="mb-2">
<strong class="text-body-2">Pay Membership Dues</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Transfer your annual membership dues using the banking details below.
</p>
</div>
</v-timeline-item>
<v-timeline-item
dot-color="success"
size="small"
icon="mdi-account-check"
>
<template #opposite>
<strong class="text-body-2">Step 4</strong>
</template>
<div>
<strong class="text-body-2">Account Activation</strong>
<p class="text-body-2 text-medium-emphasis mb-0">
Once payment is verified, your account will be activated and you can access the member portal.
</p>
</div>
</v-timeline-item>
</v-timeline>
</div>
<v-divider class="mb-6" />
<!-- Payment Information -->
<div class="payment-info mb-6">
<h4 class="text-h6 mb-3 d-flex align-center">
<v-icon class="mr-2" color="primary">mdi-bank</v-icon>
Payment Instructions
</h4>
<v-card variant="outlined" class="pa-4" color="primary-lighten-5">
<v-row dense>
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Amount:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-1 font-weight-bold">{{ paymentInfo?.membershipFee || '50' }}/year</span>
</v-col>
</v-row>
<v-row dense v-if="paymentInfo?.iban" class="mb-2">
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">IBAN:</span>
</v-col>
<v-col cols="12" sm="8">
<div class="d-flex align-center">
<span class="text-body-2 font-family-monospace mr-2">{{ paymentInfo.iban }}</span>
<v-btn
icon="mdi-content-copy"
size="x-small"
variant="text"
@click="copyToClipboard(paymentInfo.iban)"
:title="'Copy IBAN'"
/>
</div>
</v-col>
</v-row>
<v-row dense v-if="paymentInfo?.accountHolder">
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Account:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-2">{{ paymentInfo.accountHolder }}</span>
</v-col>
</v-row>
<v-row dense>
<v-col cols="12" sm="4">
<span class="text-body-2 font-weight-bold">Reference:</span>
</v-col>
<v-col cols="12" sm="8">
<span class="text-body-2">Member {{ memberData?.memberId || 'Registration' }}</span>
</v-col>
</v-row>
</v-card>
</div>
<!-- Important Notes -->
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<template #title>Important Notes</template>
<ul class="text-body-2 ml-4">
<li>Check your spam folder if you don't receive the verification email within 10 minutes</li>
<li>Your membership will be activated within 2-3 business days after payment verification</li>
<li>Contact our administrators if you need assistance with the verification process</li>
</ul>
</v-alert>
<!-- Copy Notification -->
<v-snackbar
v-model="showCopyNotification"
timeout="2000"
color="success"
location="bottom"
>
IBAN copied to clipboard!
</v-snackbar>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="outlined"
@click="closeDialog"
class="mr-3"
>
Close
</v-btn>
<v-btn
color="primary"
@click="goToLogin"
>
<v-icon start>mdi-login</v-icon>
Go to Login
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
memberData?: {
memberId: string;
email: string;
};
paymentInfo?: {
membershipFee: number;
iban: string;
accountHolder: string;
};
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'go-to-login'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const showCopyNotification = ref(false);
// Methods
const closeDialog = () => {
emit('update:model-value', false);
};
const goToLogin = () => {
emit('go-to-login');
closeDialog();
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showCopyNotification.value = true;
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showCopyNotification.value = true;
}
};
</script>
<style scoped>
.registration-success-card {
border-radius: 16px !important;
}
.bg-success {
background: linear-gradient(135deg, #4caf50 0%, #66bb6a 100%) !important;
}
.payment-info .v-card {
border-radius: 12px !important;
}
/* Timeline styling */
.v-timeline :deep(.v-timeline-item__body) {
padding-bottom: 16px;
}
.v-timeline :deep(.v-timeline-item__opposite) {
padding-inline-end: 16px;
}
/* Copy button styling */
.v-btn--size-x-small {
min-width: 24px !important;
width: 24px;
height: 24px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
.v-timeline :deep(.v-timeline-item__opposite) {
display: none;
}
}
/* Print styles (if user wants to print) */
@media print {
.v-card-actions {
display: none;
}
.payment-info .v-card {
border: 2px solid #ddd !important;
}
}
</style>

View File

@ -0,0 +1,424 @@
<template>
<v-card
v-if="event"
elevation="3"
class="upcoming-event-banner ma-2"
:color="eventTypeColor"
theme="dark"
rounded="xl"
>
<v-card-text class="pa-4">
<!-- Mobile Layout -->
<div v-if="$vuetify.display.mobile" class="mobile-banner-layout">
<!-- Header -->
<div class="d-flex align-center mb-3">
<v-avatar :color="eventTypeColor" class="me-3" size="40">
<v-icon :icon="eventTypeIcon" size="20"></v-icon>
</v-avatar>
<div class="flex-grow-1">
<h3 class="text-h6 font-weight-bold text-truncate">{{ event.title }}</h3>
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
</div>
</div>
<!-- Event Details -->
<div class="mb-3">
<div class="d-flex align-center mb-1">
<v-icon size="16" class="me-2">mdi-calendar-clock</v-icon>
<span class="text-body-2">{{ formatEventDate }}</span>
</div>
<div v-if="event.location" class="d-flex align-center mb-1">
<v-icon size="16" class="me-2">mdi-map-marker</v-icon>
<span class="text-body-2 text-truncate">{{ event.location }}</span>
</div>
<div class="d-flex align-center justify-space-between">
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
<v-icon size="16" class="me-2">mdi-currency-eur</v-icon>
<span class="text-body-2">{{ priceDisplay }}</span>
</div>
<div v-if="event.max_attendees" class="d-flex align-center">
<v-icon size="16" class="me-2">mdi-account-group</v-icon>
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex ga-2">
<v-btn
@click="handleQuickRSVP"
:color="userRSVP ? 'success' : 'white'"
:variant="userRSVP ? 'elevated' : 'outlined'"
size="small"
class="text-none flex-grow-1"
rounded="lg"
>
<v-icon start size="18">
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
</v-icon>
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
</v-btn>
<v-btn
@click="handleViewDetails"
color="white"
variant="outlined"
size="small"
class="text-none"
rounded="lg"
icon
>
<v-icon size="18">mdi-eye</v-icon>
</v-btn>
</div>
</div>
<!-- Desktop Layout -->
<v-row v-else align="center" no-gutters>
<v-col cols="12" md="8">
<div class="d-flex align-center mb-2">
<v-avatar :color="eventTypeColor" class="me-3" size="32">
<v-icon :icon="eventTypeIcon" size="16"></v-icon>
</v-avatar>
<div>
<h3 class="text-h6 font-weight-bold">{{ event.title }}</h3>
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
</div>
</div>
<div class="d-flex align-center flex-wrap ga-4">
<div class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-calendar-clock</v-icon>
<span class="text-body-2">{{ formatEventDate }}</span>
</div>
<div v-if="event.location" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-map-marker</v-icon>
<span class="text-body-2">{{ event.location }}</span>
</div>
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-currency-eur</v-icon>
<span class="text-body-2">{{ priceDisplay }}</span>
</div>
<div v-if="event.max_attendees" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-account-group</v-icon>
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
</div>
</div>
</v-col>
<v-col cols="12" md="4" class="text-end">
<div class="d-flex ga-2 justify-end">
<v-btn
@click="handleQuickRSVP"
:color="userRSVP ? 'success' : 'white'"
:variant="userRSVP ? 'elevated' : 'outlined'"
size="small"
class="text-none"
rounded="lg"
>
<v-icon start size="small">
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
</v-icon>
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
</v-btn>
<v-btn
@click="handleViewDetails"
color="white"
variant="outlined"
size="small"
class="text-none"
rounded="lg"
>
<v-icon start size="small">mdi-eye</v-icon>
View Details
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
// Helper functions to replace date-fns
const formatDate = (date: Date, formatStr: string): string => {
const options: Intl.DateTimeFormatOptions = {};
if (formatStr === 'HH:mm') {
options.hour = '2-digit';
options.minute = '2-digit';
options.hour12 = false;
} else if (formatStr === 'EEE, MMM d • HH:mm') {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
}) + ' • ' + date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} else if (formatStr === 'MMM d') {
options.month = 'short';
options.day = 'numeric';
}
return date.toLocaleDateString('en-US', options);
};
const addDays = (date: Date, days: number): Date => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
const isWithinInterval = (date: Date, interval: { start: Date; end: Date }): boolean => {
return date >= interval.start && date <= interval.end;
};
interface Props {
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'event-click': [event: Event];
'quick-rsvp': [event: Event];
}>();
// Computed properties
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const canRSVP = computed(() => {
if (!props.event) return false;
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
return eventDate > now; // Can RSVP to future events
});
const eventTypeIcon = computed(() => {
if (!props.event) return 'mdi-calendar';
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event.event_type as keyof typeof icons] || 'mdi-calendar';
});
const eventTypeColor = computed(() => {
if (!props.event) return 'primary';
// Check if event is soon (within 24 hours)
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
const isSoon = isWithinInterval(eventDate, {
start: now,
end: addDays(now, 1)
});
if (isSoon) return 'warning';
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event.event_type as keyof typeof colors] || 'primary';
});
const eventTypeLabel = computed(() => {
if (!props.event) return '';
const labels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Only'
};
return labels[props.event.event_type as keyof typeof labels] || 'Event';
});
const iconColor = computed(() => {
// Use white for better contrast on colored backgrounds
return 'white';
});
const memberPrice = computed(() => props.event?.cost_members || '');
const nonMemberPrice = computed(() => props.event?.cost_non_members || '');
const priceDisplay = computed(() => {
if (!props.event || props.event.is_paid !== 'true') return '';
const memberCost = props.event.cost_members;
const nonMemberCost = props.event.cost_non_members;
if (memberCost && nonMemberCost) {
// Show both prices
return `${memberCost} (Members) | €${nonMemberCost} (Non-Members)`;
} else if (memberCost) {
// Only member price
return `${memberCost} (Members)`;
} else if (nonMemberCost) {
// Only non-member price
return `${nonMemberCost}`;
}
return '';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
const now = new Date();
// Different formats based on timing
if (startDate.toDateString() === now.toDateString()) {
return `Today at ${formatDate(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === addDays(now, 1).toDateString()) {
return `Tomorrow at ${formatDate(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === endDate.toDateString()) {
return formatDate(startDate, 'EEE, MMM d • HH:mm');
}
return `${formatDate(startDate, 'MMM d')} - ${formatDate(endDate, 'MMM d')}`;
});
const capacityInfo = computed(() => {
if (!props.event?.max_attendees) return '';
const current = props.event.current_attendees || 0;
const max = parseInt(props.event.max_attendees);
return `${current}/${max} attending`;
});
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close';
default: return 'mdi-help';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'Attending';
case 'waitlist': return 'Waitlisted';
case 'declined': return 'Declined';
default: return 'Unknown';
}
});
const quickRSVPColor = computed(() => {
return eventTypeColor.value === 'warning' ? 'success' : 'white';
});
// Methods
const handleViewEvent = () => {
if (props.event) {
emit('event-click', props.event);
}
};
const handleViewDetails = () => {
if (props.event) {
emit('event-click', props.event);
}
};
const handleQuickRSVP = () => {
if (props.event) {
emit('quick-rsvp', props.event);
}
};
</script>
<style scoped>
.v-banner :deep(.v-banner__wrapper) {
padding: 16px 24px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 16px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 16px;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.v-banner :deep(.v-banner__wrapper) {
padding: 12px 16px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 12px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 0;
margin-top: 8px;
}
.text-h6 {
font-size: 1.1rem !important;
}
}
/* Ensure proper spacing on different screen sizes */
.ga-4 {
gap: 16px;
}
.ga-2 {
gap: 8px;
}
@media (max-width: 600px) {
.ga-4 {
gap: 8px;
}
}
</style>

View File

@ -0,0 +1,725 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card v-if="member" class="member-modal">
<!-- Hero Header with Profile -->
<div class="member-hero-header">
<v-btn
icon
variant="text"
color="white"
class="close-btn"
@click="$emit('update:model-value', false)"
>
<v-icon>mdi-close</v-icon>
</v-btn>
<div class="hero-content">
<ProfileAvatar
:member-id="member.member_id"
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
:first-name="member.first_name"
:last-name="member.last_name"
size="120"
class="mb-4 elevation-4"
clickable
show-border
@click="openImageLightbox"
/>
<h1 class="text-h4 font-weight-bold text-white mb-2">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h1>
<div class="d-flex align-center justify-center gap-3 mb-3">
<div class="d-flex align-center">
<CountryFlag
v-if="member.nationality"
:country-code="member.nationality"
:show-name="false"
size="medium"
class="mr-2"
/>
<span class="text-white">
{{ getCountryName(member.nationality) || 'No nationality' }}
</span>
</div>
<v-divider vertical color="white" opacity="0.5" class="mx-2" />
<span class="text-white">
Member since {{ formatDate(member.member_since) || 'Unknown' }}
</span>
</div>
<!-- Status Badges -->
<div class="d-flex justify-center gap-2">
<v-chip
:color="statusColor"
variant="flat"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">{{ statusIcon }}</v-icon>
{{ member.membership_status }}
</v-chip>
<v-chip
:color="duesColor"
:variant="duesVariant"
size="small"
class="font-weight-bold"
>
<v-icon start size="16">{{ duesIcon }}</v-icon>
{{ duesText }}
</v-chip>
<v-chip
v-if="member.membership_type"
color="purple"
variant="tonal"
size="small"
class="font-weight-bold"
>
{{ member.membership_type }}
</v-chip>
</div>
</div>
</div>
<!-- Quick Actions Bar -->
<div class="quick-actions-bar">
<v-btn
v-if="!member.dues_paid_this_year"
color="success"
variant="flat"
prepend-icon="mdi-cash-check"
@click="markDuesPaid"
>
Mark Dues Paid
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-pencil"
@click="$emit('edit', member)"
>
Edit Profile
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-email"
@click="sendEmail"
>
Send Email
</v-btn>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-phone"
:disabled="!member.phone"
@click="callPhone"
>
Call
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
color="primary"
variant="tonal"
icon="mdi-dots-vertical"
v-bind="props"
/>
</template>
<v-list>
<v-list-item @click="viewPaymentHistory">
<v-list-item-title>
<v-icon start>mdi-history</v-icon>
Payment History
</v-list-item-title>
</v-list-item>
<v-list-item @click="generateInvoice">
<v-list-item-title>
<v-icon start>mdi-file-document</v-icon>
Generate Invoice
</v-list-item-title>
</v-list-item>
<v-list-item @click="exportMemberData">
<v-list-item-title>
<v-icon start>mdi-download</v-icon>
Export Data
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- Content Tabs -->
<v-card-text class="pa-0">
<v-tabs
v-model="activeTab"
bg-color="grey-lighten-4"
slider-color="primary"
>
<v-tab value="overview">
<v-icon start>mdi-account-details</v-icon>
Overview
</v-tab>
<v-tab value="payments">
<v-icon start>mdi-cash-multiple</v-icon>
Payments
</v-tab>
<v-tab value="activity">
<v-icon start>mdi-history</v-icon>
Activity
</v-tab>
<v-tab value="notes">
<v-icon start>mdi-note-text</v-icon>
Notes
</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab">
<!-- Overview Tab -->
<v-tabs-window-item value="overview">
<v-container>
<v-row>
<!-- Personal Information -->
<v-col cols="12" md="6">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-account</v-icon>
Personal Information
</v-card-title>
<v-card-text>
<div class="info-grid">
<div class="info-item">
<label>Full Name</label>
<p>{{ member.first_name }} {{ member.last_name }}</p>
</div>
<div class="info-item">
<label>Email</label>
<p>
<a :href="`mailto:${member.email}`" class="text-primary">
{{ member.email }}
</a>
</p>
</div>
<div class="info-item" v-if="member.phone">
<label>Phone</label>
<p>
<a :href="`tel:${member.phone}`" class="text-primary">
{{ member.FormattedPhone || member.phone }}
</a>
</p>
</div>
<div class="info-item" v-if="member.date_of_birth">
<label>Date of Birth</label>
<p>{{ formatDate(member.date_of_birth) }}</p>
</div>
<div class="info-item" v-if="member.address">
<label>Address</label>
<p>{{ member.address }}</p>
</div>
<div class="info-item">
<label>Nationality</label>
<div class="d-flex align-center">
<CountryFlag
v-if="member.nationality"
:country-code="member.nationality"
:show-name="false"
size="small"
class="mr-2"
/>
<span>{{ getCountryName(member.nationality) || 'Not specified' }}</span>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Membership Information -->
<v-col cols="12" md="6">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-card-account-details</v-icon>
Membership Details
</v-card-title>
<v-card-text>
<div class="info-grid">
<div class="info-item">
<label>Member ID</label>
<p>{{ member.member_id }}</p>
</div>
<div class="info-item">
<label>Membership Type</label>
<v-chip :color="getMembershipColor(member.membership_type)" size="small" variant="tonal">
{{ member.membership_type }}
</v-chip>
</div>
<div class="info-item">
<label>Status</label>
<v-chip :color="statusColor" size="small" variant="flat">
{{ member.membership_status }}
</v-chip>
</div>
<div class="info-item">
<label>Member Since</label>
<p>{{ formatDate(member.member_since) || 'Not specified' }}</p>
</div>
<div class="info-item">
<label>Last Renewal</label>
<p>{{ member.last_renewal ? formatDate(member.last_renewal) : 'Never' }}</p>
</div>
<div class="info-item">
<label>Dues Status</label>
<v-chip :color="duesColor" size="small" :variant="duesVariant">
{{ duesText }}
</v-chip>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Emergency Contact -->
<v-col cols="12" v-if="member.emergency_contact">
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="error">mdi-phone-alert</v-icon>
Emergency Contact
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<div class="info-item">
<label>Name</label>
<p>{{ member.emergency_contact.name || 'Not provided' }}</p>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="info-item">
<label>Relationship</label>
<p>{{ member.emergency_contact.relationship || 'Not provided' }}</p>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="info-item">
<label>Phone</label>
<p>{{ member.emergency_contact.phone || 'Not provided' }}</p>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-tabs-window-item>
<!-- Payments Tab -->
<v-tabs-window-item value="payments">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon start color="primary">mdi-cash-multiple</v-icon>
Payment History
</div>
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-plus"
@click="recordPayment"
>
Record Payment
</v-btn>
</v-card-title>
<v-card-text>
<v-list lines="two" class="pa-0">
<v-list-item
v-for="payment in recentPayments"
:key="payment.id"
class="px-0"
>
<template v-slot:prepend>
<v-icon :color="payment.status === 'Completed' ? 'success' : 'warning'">
{{ payment.status === 'Completed' ? 'mdi-check-circle' : 'mdi-clock-outline' }}
</v-icon>
</template>
<v-list-item-title>
${{ payment.amount }} - {{ payment.type }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(payment.date) }} {{ payment.method }}
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
:color="payment.status === 'Completed' ? 'success' : 'warning'"
size="small"
variant="tonal"
>
{{ payment.status }}
</v-chip>
</template>
</v-list-item>
</v-list>
<div v-if="!recentPayments || recentPayments.length === 0" class="text-center py-8 text-medium-emphasis">
No payment history available
</div>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
<!-- Activity Tab -->
<v-tabs-window-item value="activity">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center">
<v-icon start color="primary">mdi-history</v-icon>
Recent Activity
</v-card-title>
<v-card-text>
<v-timeline side="end" density="compact">
<v-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:dot-color="activity.color"
size="small"
>
<template v-slot:opposite>
<div class="text-caption">
{{ formatRelativeTime(activity.date) }}
</div>
</template>
<div>
<div class="font-weight-medium">{{ activity.title }}</div>
<div class="text-caption text-medium-emphasis">{{ activity.description }}</div>
</div>
</v-timeline-item>
</v-timeline>
<div v-if="!recentActivities || recentActivities.length === 0" class="text-center py-8 text-medium-emphasis">
No recent activity
</div>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
<!-- Notes Tab -->
<v-tabs-window-item value="notes">
<v-container>
<v-card elevation="0" class="info-card">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon start color="primary">mdi-note-text</v-icon>
Member Notes
</div>
<v-btn
color="primary"
variant="tonal"
size="small"
prepend-icon="mdi-plus"
@click="addNote"
>
Add Note
</v-btn>
</v-card-title>
<v-card-text>
<v-textarea
v-model="memberNotes"
label="Notes about this member"
rows="6"
variant="outlined"
placeholder="Add notes about this member..."
/>
<v-btn
color="primary"
variant="flat"
@click="saveNotes"
:disabled="!memberNotes"
>
Save Notes
</v-btn>
</v-card-text>
</v-card>
</v-container>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
<!-- Footer Actions -->
<v-card-actions class="pa-4 bg-grey-lighten-5">
<v-spacer />
<v-btn
variant="text"
@click="$emit('update:model-value', false)"
>
Close
</v-btn>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-pencil"
@click="$emit('edit', member)"
>
Edit Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { countries } from '~/utils/countries';
interface Props {
modelValue: boolean;
member: Member | null;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'edit', member: Member): void;
(e: 'mark-dues-paid', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// State
const activeTab = ref('overview');
const memberNotes = ref('');
const recentPayments = ref([]);
const recentActivities = ref([]);
// Computed properties
const statusColor = computed(() => {
if (!props.member) return 'default';
return props.member.membership_status === 'Active' ? 'success' : 'error';
});
const statusIcon = computed(() => {
if (!props.member) return 'mdi-account';
return props.member.membership_status === 'Active' ? 'mdi-check-circle' : 'mdi-close-circle';
});
const duesColor = computed(() => {
if (!props.member) return 'default';
if (props.member.dues_paid_this_year) return 'success';
if (props.member.dues_status === 'Overdue') return 'error';
return 'warning';
});
const duesVariant = computed(() => {
if (!props.member) return 'tonal';
return props.member.dues_paid_this_year ? 'flat' : 'tonal';
});
const duesIcon = computed(() => {
if (!props.member) return 'mdi-cash';
if (props.member.dues_paid_this_year) return 'mdi-check-circle';
if (props.member.dues_status === 'Overdue') return 'mdi-alert-circle';
return 'mdi-clock-outline';
});
const duesText = computed(() => {
if (!props.member) return 'Unknown';
if (props.member.dues_paid_this_year) return 'Dues Paid';
if (props.member.dues_status === 'Overdue') return 'Dues Overdue';
return 'Dues Due';
});
const isOverdue = computed(() => {
if (!props.member || !props.member.payment_due_date) return false;
return new Date(props.member.payment_due_date) < new Date();
});
// Methods
const getCountryName = (code: string) => {
if (!code) return null;
const country = countries.find(c => c.code === code);
return country ? country.name : code;
};
const getMembershipColor = (type: string) => {
switch (type) {
case 'VIP': return 'error';
case 'Premium': return 'warning';
case 'Lifetime': return 'purple';
default: return 'info';
}
};
const formatDate = (date: string) => {
if (!date) return 'N/A';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) return 'N/A';
return parsedDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
});
};
const formatRelativeTime = (date: string) => {
const now = new Date();
const then = new Date(date);
const diff = now.getTime() - then.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
};
const openImageLightbox = () => {
// TODO: Implement image lightbox
};
const markDuesPaid = () => {
if (props.member) {
emit('mark-dues-paid', props.member);
}
};
const sendEmail = () => {
if (props.member) {
window.location.href = `mailto:${props.member.email}`;
}
};
const callPhone = () => {
if (props.member && props.member.phone) {
window.location.href = `tel:${props.member.phone}`;
}
};
const viewPaymentHistory = () => {
activeTab.value = 'payments';
};
const generateInvoice = () => {
// TODO: Generate invoice for member
};
const exportMemberData = () => {
// TODO: Export member data
};
const recordPayment = () => {
// TODO: Record payment for member
};
const addNote = () => {
// Focus on notes textarea
activeTab.value = 'notes';
};
const saveNotes = () => {
// TODO: Save notes to database
};
// Load member-specific data when dialog opens
watch(() => props.modelValue, (newVal) => {
if (newVal && props.member) {
// Reset to overview tab
activeTab.value = 'overview';
// Load member notes
memberNotes.value = props.member.notes || '';
// TODO: Load payment history and activities
}
});
</script>
<style scoped>
.member-modal {
overflow: hidden;
}
.member-hero-header {
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 3rem 2rem;
text-align: center;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
}
.hero-content {
position: relative;
z-index: 0;
}
.quick-actions-bar {
display: flex;
gap: 0.5rem;
padding: 1rem;
background-color: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
overflow-x: auto;
}
.info-card {
border: 1px solid #e0e0e0;
}
.info-grid {
display: grid;
gap: 1.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-item label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #666;
letter-spacing: 0.5px;
}
.info-item p {
margin: 0;
font-size: 1rem;
color: #333;
}
.info-item a {
text-decoration: none;
}
.info-item a:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,285 @@
<template>
<div class="activity-timeline">
<div
v-for="(item, index) in activities"
:key="item.id"
v-motion
:initial="{ opacity: 0, x: -20 }"
:visibleOnce="{
opacity: 1,
x: 0,
transition: {
delay: index * 100,
duration: 500,
type: 'spring',
stiffness: 200
}
}"
class="timeline-item"
:class="{ 'timeline-item--last': index === activities.length - 1 }"
>
<!-- Timeline Marker -->
<div
class="timeline-marker"
:class="[
`timeline-marker--${item.type}`,
{ 'timeline-marker--pulse': item.isNew }
]"
>
<v-icon
:color="getIconColor(item.type)"
size="16"
>
{{ item.icon }}
</v-icon>
</div>
<!-- Timeline Content -->
<div class="timeline-content">
<div class="timeline-header">
<h4 class="timeline-title">{{ item.title }}</h4>
<span class="timeline-time">{{ formatTime(item.timestamp) }}</span>
</div>
<p class="timeline-description">{{ item.description }}</p>
<!-- Optional metadata -->
<div v-if="item.metadata" class="timeline-metadata">
<v-chip
v-for="(meta, key) in item.metadata"
:key="key"
size="x-small"
variant="tonal"
:color="getMetaColor(key)"
class="mr-1"
>
{{ meta }}
</v-chip>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface TimelineActivity {
id: string | number;
type: 'event' | 'profile';
title: string;
description: string;
timestamp: string | Date;
icon: string;
isNew?: boolean;
metadata?: Record<string, any>;
}
interface Props {
activities: TimelineActivity[];
maxItems?: number;
}
const props = withDefaults(defineProps<Props>(), {
maxItems: 10
});
// Compute visible activities
const visibleActivities = computed(() => {
return props.activities.slice(0, props.maxItems);
});
// Get icon color based on activity type
const getIconColor = (type: string) => {
const colors: Record<string, string> = {
event: 'error',
profile: 'info'
};
return colors[type] || 'grey';
};
// Get metadata chip color
const getMetaColor = (key: string) => {
const colors: Record<string, string> = {
status: 'success',
category: 'primary',
amount: 'warning',
level: 'info'
};
return colors[key] || 'grey';
};
// Format timestamp
const formatTime = (timestamp: string | Date) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
</script>
<style scoped lang="scss">
.activity-timeline {
position: relative;
padding-left: 2rem;
// Vertical line
&::before {
content: '';
position: absolute;
left: 0.75rem;
top: 0.5rem;
bottom: 1rem;
width: 2px;
background: linear-gradient(
to bottom,
rgba(220, 38, 38, 0.3),
rgba(220, 38, 38, 0.1),
transparent
);
}
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
&--last {
padding-bottom: 0;
}
}
.timeline-marker {
position: absolute;
left: -1.25rem;
top: 0.125rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: white;
border: 2px solid;
z-index: 1;
transition: all 0.3s ease;
&--event {
border-color: rgb(220, 38, 38);
background: linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05));
}
&--profile {
border-color: rgb(59, 130, 246);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05));
}
&--pulse {
&::after {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid currentColor;
opacity: 0;
animation: pulse-ring 2s infinite;
}
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.3;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.timeline-content {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.6)
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:hover {
transform: translateX(4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.timeline-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.timeline-time {
font-size: 0.75rem;
color: rgb(156, 163, 175);
white-space: nowrap;
}
.timeline-description {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
line-height: 1.5;
}
.timeline-metadata {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
@media (max-width: 640px) {
.activity-timeline {
padding-left: 1.5rem;
}
.timeline-marker {
left: -1rem;
width: 1.25rem;
height: 1.25rem;
}
.timeline-content {
padding: 0.75rem;
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="bento-grid" :class="gridClass">
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
columns?: number;
gap?: 'sm' | 'md' | 'lg' | 'xl';
responsive?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
columns: 12,
gap: 'md',
responsive: true
});
const gridClass = computed(() => {
return {
[`bento-grid--cols-${props.columns}`]: true,
[`bento-grid--gap-${props.gap}`]: true,
'bento-grid--responsive': props.responsive
};
});
</script>
<style scoped lang="scss">
.bento-grid {
display: grid;
width: 100%;
// Column configurations
&--cols-12 {
grid-template-columns: repeat(12, 1fr);
}
&--cols-6 {
grid-template-columns: repeat(6, 1fr);
}
&--cols-4 {
grid-template-columns: repeat(4, 1fr);
}
&--cols-3 {
grid-template-columns: repeat(3, 1fr);
}
// Gap sizes
&--gap-sm {
gap: 0.75rem;
}
&--gap-md {
gap: 1.25rem;
}
&--gap-lg {
gap: 1.75rem;
}
&--gap-xl {
gap: 2.25rem;
}
// Responsive behavior
&--responsive {
@media (max-width: 640px) {
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 641px) and (max-width: 768px) {
grid-template-columns: repeat(6, 1fr);
}
@media (min-width: 769px) and (max-width: 1024px) {
grid-template-columns: repeat(8, 1fr);
}
}
}
// Global Bento Item Classes
:deep(.bento-item) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
// Size variants
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium) {
grid-column: span 4;
}
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 8;
}
:deep(.bento-item--full) {
grid-column: span 12;
}
// Height variants
:deep(.bento-item--tall) {
grid-row: span 2;
}
:deep(.bento-item--xtall) {
grid-row: span 3;
}
// Responsive overrides
@media (max-width: 640px) {
:deep(.bento-item--small),
:deep(.bento-item--medium),
:deep(.bento-item--large),
:deep(.bento-item--xlarge) {
grid-column: span 12;
}
}
@media (min-width: 641px) and (max-width: 768px) {
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium),
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 6;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
:deep(.bento-item--small) {
grid-column: span 3;
}
:deep(.bento-item--medium) {
grid-column: span 4;
}
:deep(.bento-item--large) {
grid-column: span 6;
}
:deep(.bento-item--xlarge) {
grid-column: span 8;
}
}
</style>

View File

@ -0,0 +1,303 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 400,
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="events-card"
>
<div class="events-header">
<div class="header-left">
<v-icon color="error" size="20">mdi-calendar</v-icon>
<h3 class="events-title">Upcoming Events</h3>
</div>
<v-btn
variant="text"
color="error"
size="small"
@click="$emit('view-all')"
>
View All
<v-icon end size="16">mdi-arrow-right</v-icon>
</v-btn>
</div>
<div class="events-list">
<div
v-for="(event, index) in events"
:key="event.id"
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{
opacity: 1,
x: 0,
transition: {
delay: 500 + (index * 100),
duration: 500,
type: 'spring'
}
}"
class="event-item"
:class="{ 'event-item--pending': event.status === 'pending' }"
>
<div class="event-date">
<div class="date-month">{{ formatMonth(event.date) }}</div>
<div class="date-day">{{ formatDay(event.date) }}</div>
</div>
<div class="event-details">
<h4 class="event-name">{{ event.title }}</h4>
<div class="event-meta">
<span class="event-time">
<v-icon size="14" color="grey">mdi-clock-outline</v-icon>
{{ event.time }}
</span>
<span class="event-location">
<v-icon size="14" color="grey">mdi-map-marker</v-icon>
{{ event.location }}
</span>
</div>
</div>
<div class="event-status">
<v-chip
:color="event.status === 'confirmed' ? 'success' : 'warning'"
size="x-small"
variant="tonal"
>
{{ event.status }}
</v-chip>
</div>
</div>
</div>
<div class="events-footer">
<div class="footer-message">
<v-icon size="16" color="grey">mdi-information</v-icon>
<span>{{ events.length }} upcoming event{{ events.length !== 1 ? 's' : '' }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Event {
id: string;
title: string;
date: string;
time: string;
location: string;
status: 'confirmed' | 'pending';
}
interface Props {
events: Event[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
'view-all': [];
}>();
// Computed stats
const confirmedCount = computed(() =>
props.events.filter(e => e.status === 'confirmed').length
);
const pendingCount = computed(() =>
props.events.filter(e => e.status === 'pending').length
);
// Date formatting
const formatMonth = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
};
const formatDay = (dateString: string) => {
return new Date(dateString).getDate();
};
</script>
<style scoped lang="scss">
.events-card {
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.events-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.events-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.events-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-right: 0.5rem;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: rgba(220, 38, 38, 0.05);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(220, 38, 38, 0.2);
border-radius: 2px;
&:hover {
background: rgba(220, 38, 38, 0.3);
}
}
}
.event-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8),
rgba(255, 255, 255, 0.6)
);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
cursor: pointer;
&:hover {
transform: translateX(4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
border-color: rgba(220, 38, 38, 0.2);
}
&--pending {
opacity: 0.8;
border-style: dashed;
}
}
.event-date {
flex-shrink: 0;
width: 48px;
height: 48px;
background: linear-gradient(135deg, #dc2626, #b91c1c);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
}
.date-month {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.date-day {
font-size: 1.25rem;
font-weight: 700;
line-height: 1;
}
.event-details {
flex: 1;
min-width: 0;
}
.event-name {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: rgb(107, 114, 128);
span {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.event-status {
flex-shrink: 0;
align-self: center;
}
.events-footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(220, 38, 38, 0.1);
}
.footer-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: rgb(107, 114, 128);
}
@media (max-width: 640px) {
.event-meta {
flex-direction: column;
gap: 0.25rem;
}
}
</style>

View File

@ -0,0 +1,348 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
delay: 600,
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="payment-card"
>
<!-- Card Header -->
<div class="payment-header">
<div class="header-left">
<v-icon color="success" size="20">mdi-credit-card</v-icon>
<h3 class="payment-title">Payment Status</h3>
</div>
<v-chip
color="success"
variant="tonal"
size="small"
>
<v-icon start size="14">mdi-check-circle</v-icon>
Active
</v-chip>
</div>
<!-- Membership Info -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 700,
duration: 500
}
}"
class="membership-info"
>
<div class="info-row">
<span class="info-label">Membership Type</span>
<span class="info-value">{{ membershipType }}</span>
</div>
<div class="info-row">
<span class="info-label">Next Payment</span>
<span class="info-value">{{ nextPaymentDate }}</span>
</div>
<div class="info-row">
<span class="info-label">Amount</span>
<span class="info-value amount">${{ membershipAmount }}</span>
</div>
</div>
<!-- Payment Method -->
<div
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 800,
duration: 500
}
}"
class="payment-method"
>
<div class="method-header">
<span class="method-label">Payment Method</span>
<v-btn
variant="text"
color="error"
size="x-small"
@click="$emit('update-payment')"
>
Update
</v-btn>
</div>
<div class="method-card">
<v-icon color="primary" size="20">mdi-credit-card</v-icon>
<span class="card-number"> 4242</span>
<span class="card-exp">12/25</span>
</div>
</div>
<!-- Recent Payments -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 900,
duration: 500
}
}"
class="recent-payments"
>
<h4 class="payments-title">Recent Payments</h4>
<div class="payments-list">
<div
v-for="(payment, index) in paymentHistory"
:key="payment.id"
v-motion
:initial="{ opacity: 0, x: -10 }"
:visibleOnce="{
opacity: 1,
x: 0,
transition: {
delay: 1000 + (index * 50),
duration: 400
}
}"
class="payment-item"
>
<v-icon
size="16"
:color="index === 0 ? 'success' : 'grey'"
>
mdi-check-circle
</v-icon>
<span class="payment-date">{{ payment.date }}</span>
<span class="payment-amount">${{ payment.amount }}</span>
</div>
</div>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="outlined"
block
class="mt-4"
prepend-icon="mdi-history"
@click="$emit('update-payment')"
>
View Payment History
</v-btn>
</div>
</template>
<script setup lang="ts">
interface Payment {
id: number;
date: string;
amount: string;
}
interface Props {
membershipType: string;
nextPaymentDate: string;
membershipAmount: string;
paymentHistory: Payment[];
}
defineProps<Props>();
defineEmits<{
'update-payment': [];
}>();
</script>
<style scoped lang="scss">
.payment-card {
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.payment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.payment-title {
font-size: 1.125rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
}
.membership-info {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.05),
rgba(34, 197, 94, 0.02)
);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
border: 1px solid rgba(34, 197, 94, 0.1);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
&:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
}
.info-label {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
font-weight: 500;
}
.info-value {
font-size: 0.875rem;
color: rgb(31, 41, 55);
font-weight: 600;
&.amount {
font-size: 1.125rem;
background: linear-gradient(135deg, #22c55e, #16a34a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.payment-method {
background: rgba(255, 255, 255, 0.5);
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 1.25rem;
}
.method-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.method-label {
font-size: 0.8125rem;
color: rgb(107, 114, 128);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.method-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.card-number {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: rgb(31, 41, 55);
letter-spacing: 0.05em;
}
.card-exp {
font-size: 0.75rem;
color: rgb(107, 114, 128);
}
.recent-payments {
flex: 1;
margin-bottom: 1rem;
}
.payments-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.75rem 0;
}
.payments-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.payment-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.3);
border-radius: 0.5rem;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.5);
transform: translateX(2px);
}
}
.payment-date {
flex: 1;
font-size: 0.8125rem;
color: rgb(107, 114, 128);
}
.payment-amount {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
}
@media (max-width: 640px) {
.payment-card {
padding: 1rem;
}
}
</style>

View File

@ -0,0 +1,443 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
duration: 600,
type: 'spring',
stiffness: 200
}
}"
class="profile-card"
>
<!-- Background Gradient -->
<div class="profile-background">
<div class="profile-gradient"></div>
<div class="profile-pattern"></div>
</div>
<!-- Content -->
<div class="profile-content">
<!-- Header Section -->
<div class="profile-header">
<div class="profile-avatar-wrapper">
<div
v-motion
:initial="{ scale: 0 }"
:enter="{
scale: 1,
transition: {
delay: 200,
type: 'spring',
stiffness: 200
}
}"
class="profile-avatar"
>
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="x-large"
:show-badge="false"
/>
</div>
<div class="profile-level-badge">
<v-icon size="16" color="white">mdi-star</v-icon>
<span>{{ memberLevel }}</span>
</div>
</div>
<div class="profile-info">
<h2
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 300,
duration: 500
}
}"
class="profile-name"
>
{{ fullName }}
</h2>
<p class="profile-email">{{ email }}</p>
<div class="profile-badges">
<v-chip
color="error"
variant="tonal"
size="small"
class="profile-badge"
>
<v-icon start size="14">mdi-crown</v-icon>
{{ membershipType }}
</v-chip>
<v-chip
variant="outlined"
color="error"
size="small"
class="profile-badge"
>
<v-icon start size="14">mdi-calendar</v-icon>
Since {{ memberSince }}
</v-chip>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="profile-stats">
<div
v-for="(stat, index) in stats"
:key="stat.label"
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: 400 + (index * 100),
duration: 500
}
}"
class="stat-item"
>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
<!-- Progress Section -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{
opacity: 1,
transition: {
delay: 700,
duration: 500
}
}"
class="profile-progress"
>
<div class="progress-header">
<span class="progress-title">Level Progress</span>
<span class="progress-percentage">{{ levelProgress }}%</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${levelProgress}%` }"
></div>
</div>
<p class="progress-subtitle">
{{ pointsToNext }} points to {{ nextLevel }}
</p>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="flat"
block
class="profile-action mt-4"
prepend-icon="mdi-account-edit"
@click="$emit('edit-profile')"
>
Edit Profile
</v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Member } from '~/utils/types';
interface Props {
member: Member | null;
memberPoints?: number;
eventsAttended?: number;
connections?: number;
}
const props = withDefaults(defineProps<Props>(), {
memberPoints: 2450,
eventsAttended: 12,
connections: 48
});
const emit = defineEmits<{
'edit-profile': [];
}>();
// Computed properties
const fullName = computed(() => {
if (props.member) {
return `${props.member.first_name} ${props.member.last_name}`;
}
return 'Member';
});
const email = computed(() => props.member?.email || '');
const membershipType = computed(() => 'Premium');
const memberLevel = computed(() => 'Gold');
const memberSince = computed(() => {
if (props.member?.join_date) {
return new Date(props.member.join_date).getFullYear();
}
return new Date().getFullYear();
});
// Stats data
const stats = computed(() => [
{ label: 'Points', value: props.memberPoints.toLocaleString() },
{ label: 'Events', value: props.eventsAttended },
{ label: 'Connections', value: props.connections }
]);
// Level progress calculation
const levelProgress = computed(() => {
// Calculate progress to next level (mock calculation)
const currentLevelMin = 2000;
const nextLevelMin = 3000;
const progress = ((props.memberPoints - currentLevelMin) / (nextLevelMin - currentLevelMin)) * 100;
return Math.min(Math.max(progress, 0), 100).toFixed(0);
});
const pointsToNext = computed(() => {
const nextLevelMin = 3000;
return nextLevelMin - props.memberPoints;
});
const nextLevel = computed(() => 'Platinum');
</script>
<style scoped lang="scss">
.profile-card {
position: relative;
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.profile-background {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
overflow: hidden;
}
.profile-gradient {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.9),
rgba(185, 28, 28, 0.9)
);
}
.profile-pattern {
position: absolute;
inset: 0;
opacity: 0.1;
background-image:
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(255,255,255,.1) 35px, rgba(255,255,255,.1) 70px);
}
.profile-content {
position: relative;
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
}
.profile-header {
display: flex;
align-items: flex-start;
gap: 1.25rem;
margin-bottom: 1.5rem;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 1rem;
overflow: hidden;
border: 4px solid white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.profile-level-badge {
position: absolute;
bottom: -4px;
right: -4px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.5rem;
font-size: 0.625rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.125rem;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.profile-info {
flex: 1;
padding-top: 0.5rem;
}
.profile-name {
font-size: 1.25rem;
font-weight: 700;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
}
.profile-email {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0 0 0.75rem 0;
}
.profile-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.profile-badge {
font-weight: 500;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
padding: 1.25rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.03),
rgba(220, 38, 38, 0.01)
);
border-radius: 0.75rem;
margin-bottom: 1.25rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, #dc2626, #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: rgb(156, 163, 175);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-progress {
padding: 1rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.progress-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(31, 41, 55);
}
.progress-percentage {
font-size: 0.875rem;
font-weight: 700;
color: rgb(220, 38, 38);
}
.progress-bar {
height: 8px;
background: rgba(220, 38, 38, 0.1);
border-radius: 9999px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #dc2626, #ef4444);
border-radius: 9999px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}
.progress-subtitle {
font-size: 0.75rem;
color: rgb(156, 163, 175);
margin: 0;
}
.profile-action {
margin-top: auto;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-badges {
justify-content: center;
}
}
</style>

View File

@ -0,0 +1,163 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20, scale: 0.9 }"
:enter="{
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: delay,
duration: 500,
type: 'spring',
stiffness: 200
}
}"
:hovered="{
scale: 1.05,
y: -5,
transition: {
duration: 200
}
}"
class="quick-action-card"
@click="$emit('click')"
>
<div class="action-icon" :style="{ background: iconBackground }">
<v-icon :color="color" size="28">{{ icon }}</v-icon>
</div>
<h4 class="action-title">{{ title }}</h4>
<v-icon class="action-arrow" color="grey" size="16">mdi-arrow-right</v-icon>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
icon: string;
title: string;
color?: string;
delay?: number;
}
const props = withDefaults(defineProps<Props>(), {
color: 'error',
delay: 0
});
defineEmits<{
click: [];
}>();
// Compute icon background based on color
const iconBackground = computed(() => {
const colors: Record<string, string> = {
error: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
primary: 'linear-gradient(135deg, rgba(33, 150, 243, 0.1), rgba(33, 150, 243, 0.05))',
success: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.05))',
warning: 'linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(245, 158, 11, 0.05))',
info: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05))'
};
return colors[props.color] || colors.error;
});
</script>
<style scoped lang="scss">
.quick-action-card {
position: relative;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
rgba(220, 38, 38, 0.3),
rgba(220, 38, 38, 0.1),
transparent
);
transform: translateX(-100%);
transition: transform 0.3s ease;
}
&:hover {
border-color: rgba(220, 38, 38, 0.2);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
&::before {
transform: translateX(0);
}
.action-arrow {
transform: translateX(4px);
color: rgb(220, 38, 38) !important;
}
.action-icon {
transform: rotate(-5deg) scale(1.1);
}
}
}
.action-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.action-title {
font-size: 0.9375rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0;
line-height: 1.4;
}
.action-arrow {
position: absolute;
top: 1.5rem;
right: 1.5rem;
transition: all 0.3s ease;
}
@media (max-width: 640px) {
.quick-action-card {
padding: 1rem;
}
.action-icon {
width: 48px;
height: 48px;
}
.action-title {
font-size: 0.875rem;
}
}
</style>

View File

@ -0,0 +1,296 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.98 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
duration: 500,
type: 'spring',
stiffness: 200
}
}"
class="simple-profile-card"
>
<!-- Header with Avatar -->
<div class="profile-header">
<div class="profile-avatar-wrapper">
<ProfileAvatar
v-if="member"
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="x-large"
:show-badge="false"
/>
</div>
<div class="profile-title">
<h2 class="profile-name">{{ fullName }}</h2>
<p class="profile-member-id">{{ member?.member_id || 'MUSA-0000' }}</p>
</div>
</div>
<!-- Profile Information -->
<div class="profile-info-section">
<h3 class="section-title">Contact Information</h3>
<div class="info-grid">
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-email</v-icon>
<div class="info-content">
<span class="info-label">Email</span>
<span class="info-value">{{ member?.email || 'Not provided' }}</span>
<v-chip
v-if="emailVerified"
size="x-small"
color="success"
variant="tonal"
class="ml-2"
>
Verified
</v-chip>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-phone</v-icon>
<div class="info-content">
<span class="info-label">Phone</span>
<span class="info-value">{{ member?.phone || 'Not provided' }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-map-marker</v-icon>
<div class="info-content">
<span class="info-label">Address</span>
<span class="info-value">{{ member?.address || 'Not provided' }}</span>
</div>
</div>
</div>
</div>
<!-- Personal Information -->
<div class="profile-info-section">
<h3 class="section-title">Personal Information</h3>
<div class="info-grid">
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-flag</v-icon>
<div class="info-content">
<span class="info-label">Nationality</span>
<span class="info-value">{{ formatNationality(member?.nationality) }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-cake</v-icon>
<div class="info-content">
<span class="info-label">Date of Birth</span>
<span class="info-value">{{ formatDate(member?.date_of_birth) }}</span>
</div>
</div>
<div class="info-item">
<v-icon size="18" color="grey-darken-1">mdi-calendar-account</v-icon>
<div class="info-content">
<span class="info-label">Member Since</span>
<span class="info-value">{{ formatDate(member?.member_since) }}</span>
</div>
</div>
</div>
</div>
<!-- Bio Section (if available) -->
<div v-if="member?.bio" class="profile-info-section">
<h3 class="section-title">About Me</h3>
<p class="bio-text">{{ member.bio }}</p>
</div>
<!-- Action Button -->
<v-btn
color="error"
variant="flat"
block
class="profile-action"
prepend-icon="mdi-account-edit"
@click="$emit('edit-profile')"
>
Edit Profile
</v-btn>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Member } from '~/utils/types';
interface Props {
member: Member | null;
emailVerified?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
emailVerified: false
});
const emit = defineEmits<{
'edit-profile': [];
}>();
// Computed properties
const fullName = computed(() => {
if (props.member) {
return `${props.member.first_name} ${props.member.last_name}`;
}
return 'Member';
});
// Format nationality (handles multiple nationalities)
const formatNationality = (nationality?: string) => {
if (!nationality) return 'Not provided';
// Split by comma if multiple nationalities
const nationalities = nationality.split(',').map(n => n.trim());
// Map country codes to full names if needed
const countryMap: Record<string, string> = {
'US': 'United States',
'FR': 'France',
'MC': 'Monaco',
'IT': 'Italy',
'UK': 'United Kingdom',
'DE': 'Germany',
'ES': 'Spain'
};
return nationalities.map(n => countryMap[n] || n).join(', ');
};
// Format date
const formatDate = (dateString?: string) => {
if (!dateString) return 'Not provided';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateString;
}
};
</script>
<style scoped lang="scss">
.simple-profile-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
height: 100%;
display: flex;
flex-direction: column;
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
margin-bottom: 1.5rem;
}
.profile-avatar-wrapper {
flex-shrink: 0;
}
.profile-title {
flex: 1;
}
.profile-name {
font-size: 1.5rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin: 0 0 0.25rem 0;
}
.profile-member-id {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0;
font-family: 'Courier New', monospace;
}
.profile-info-section {
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: rgb(107, 114, 128);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 1rem 0;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.info-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.info-label {
font-size: 0.75rem;
color: rgb(156, 163, 175);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.info-value {
font-size: 0.875rem;
color: rgb(31, 41, 55);
line-height: 1.4;
}
.bio-text {
font-size: 0.875rem;
color: rgb(75, 85, 99);
line-height: 1.6;
margin: 0;
}
.profile-action {
margin-top: auto;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,332 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20, scale: 0.95 }"
:enter="{
opacity: 1,
y: 0,
scale: 1,
transition: {
delay: delay,
duration: 600,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
:hovered="{
scale: 1.02,
y: -2,
transition: {
duration: 200
}
}"
class="stats-card"
>
<div class="stats-card-inner">
<!-- Icon Section -->
<div class="stats-icon" :style="{ background: iconBackground }">
<v-icon :color="iconColor" size="24">{{ icon }}</v-icon>
</div>
<!-- Content Section -->
<div class="stats-content">
<p class="stats-label">{{ label }}</p>
<div class="stats-value-wrapper">
<h3
class="stats-value"
v-motion
:initial="{ opacity: 0 }"
:visible="{
opacity: 1,
transition: {
delay: delay + 200,
duration: 800
}
}"
>
<span v-if="prefix">{{ prefix }}</span>
<AnimatedNumber :value="value" :duration="1500" :format="formatNumber" />
<span v-if="suffix">{{ suffix }}</span>
</h3>
<div
v-if="change !== undefined"
class="stats-change"
:class="changeClass"
v-motion
:initial="{ opacity: 0, scale: 0.8 }"
:visible="{
opacity: 1,
scale: 1,
transition: {
delay: delay + 400,
duration: 500,
type: 'spring'
}
}"
>
<v-icon size="16">
{{ change >= 0 ? 'mdi-trending-up' : 'mdi-trending-down' }}
</v-icon>
<span>{{ Math.abs(change) }}%</span>
</div>
</div>
<p v-if="subtitle" class="stats-subtitle">{{ subtitle }}</p>
</div>
<!-- Background Decoration -->
<div class="stats-decoration">
<svg viewBox="0 0 200 100" class="stats-chart">
<path
:d="sparklinePath"
fill="none"
:stroke="decorationColor"
stroke-width="2"
stroke-linecap="round"
opacity="0.2"
/>
<path
:d="sparklinePath"
fill="url(#gradient)"
opacity="0.1"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="decorationColor" stop-opacity="0.3" />
<stop offset="100%" :stop-color="decorationColor" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
interface Props {
label: string;
value: number;
icon: string;
iconColor?: string;
iconBackground?: string;
change?: number;
prefix?: string;
suffix?: string;
subtitle?: string;
delay?: number;
decorationColor?: string;
}
const props = withDefaults(defineProps<Props>(), {
iconColor: 'error',
iconBackground: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
delay: 0,
decorationColor: '#dc2626'
});
// Animated number component
const AnimatedNumber = {
props: {
value: Number,
duration: { type: Number, default: 1000 },
format: Function
},
setup(props: any) {
const displayValue = ref(0);
onMounted(() => {
const startTime = Date.now();
const startValue = 0;
const endValue = props.value;
const updateValue = () => {
const now = Date.now();
const progress = Math.min((now - startTime) / props.duration, 1);
// Easing function for smooth animation
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
displayValue.value = startValue + (endValue - startValue) * easeOutQuart;
if (progress < 1) {
requestAnimationFrame(updateValue);
} else {
displayValue.value = endValue;
}
};
updateValue();
});
const formattedValue = computed(() => {
if (props.format) {
return props.format(displayValue.value);
}
return Math.round(displayValue.value).toLocaleString();
});
return () => formattedValue.value;
}
};
// Compute change indicator class
const changeClass = computed(() => {
if (props.change === undefined) return '';
return props.change >= 0 ? 'stats-change--positive' : 'stats-change--negative';
});
// Format number function
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return Math.round(num).toLocaleString();
};
// Generate random sparkline path
const sparklinePath = computed(() => {
const points = 10;
const width = 200;
const height = 100;
const values = Array.from({ length: points }, () => Math.random() * 0.6 + 0.2);
const path = values.map((value, index) => {
const x = (index / (points - 1)) * width;
const y = height - (value * height);
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
}).join(' ');
return `${path} L ${width} ${height} L 0 ${height} Z`;
});
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
height: 100%;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
&:hover {
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
}
.stats-card-inner {
position: relative;
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
z-index: 1;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stats-content {
flex: 1;
display: flex;
flex-direction: column;
}
.stats-label {
font-size: 0.875rem;
color: rgb(107, 114, 128);
margin: 0 0 0.5rem 0;
font-weight: 500;
}
.stats-value-wrapper {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.stats-value {
font-size: 2rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #dc2626, #b91c1c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.stats-change {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
&--positive {
background: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
}
&--negative {
background: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
}
}
.stats-subtitle {
font-size: 0.75rem;
color: rgb(156, 163, 175);
margin: 0.25rem 0 0 0;
}
.stats-decoration {
position: absolute;
bottom: 0;
right: 0;
width: 60%;
height: 50%;
pointer-events: none;
}
.stats-chart {
width: 100%;
height: 100%;
}
@media (max-width: 640px) {
.stats-value {
font-size: 1.5rem;
}
.stats-card-inner {
padding: 1rem;
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<span>{{ displayValue }}</span>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
interface Props {
value: number
duration?: number
format?: (value: number) => string
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 1000,
format: (value: number) => value.toLocaleString(),
delay: 0
})
const displayValue = ref(props.format(0))
const startTimestamp = ref<number | null>(null)
const startValue = ref(0)
const animate = (timestamp: number) => {
if (!startTimestamp.value) {
startTimestamp.value = timestamp
}
const progress = Math.min((timestamp - startTimestamp.value) / props.duration, 1)
// Easing function for smooth animation
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4)
const easedProgress = easeOutQuart(progress)
const currentValue = startValue.value + (props.value - startValue.value) * easedProgress
displayValue.value = props.format(currentValue)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
const startAnimation = () => {
startTimestamp.value = null
if (props.delay > 0) {
setTimeout(() => {
requestAnimationFrame(animate)
}, props.delay)
} else {
requestAnimationFrame(animate)
}
}
watch(() => props.value, (newValue, oldValue) => {
startValue.value = oldValue || 0
startAnimation()
})
onMounted(() => {
startAnimation()
})
</script>

View File

@ -0,0 +1,417 @@
<template>
<div
class="floating-input"
:class="[
`floating-input--${variant}`,
{
'floating-input--focused': isFocused || modelValue,
'floating-input--error': error,
'floating-input--disabled': disabled
}
]"
>
<div class="floating-input__wrapper">
<Icon
v-if="leftIcon"
:name="leftIcon"
class="floating-input__icon floating-input__icon--left"
/>
<input
:id="inputId"
v-model="modelValue"
:type="type"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
class="floating-input__field"
:class="{
'floating-input__field--with-left-icon': leftIcon,
'floating-input__field--with-right-icon': rightIcon || clearable
}"
@focus="handleFocus"
@blur="handleBlur"
@input="$emit('update:modelValue', $event.target.value)"
/>
<label
:for="inputId"
class="floating-input__label"
:class="{
'floating-input__label--floating': isFocused || modelValue,
'floating-input__label--with-icon': leftIcon
}"
>
{{ label }}
<span v-if="required" class="floating-input__required">*</span>
</label>
<button
v-if="clearable && modelValue"
type="button"
class="floating-input__clear"
@click="clearInput"
>
<Icon name="x" />
</button>
<Icon
v-if="rightIcon && !clearable"
:name="rightIcon"
class="floating-input__icon floating-input__icon--right"
/>
</div>
<Transition name="message">
<div v-if="error || helperText" class="floating-input__message">
<Icon
v-if="error"
name="alert-circle"
class="floating-input__message-icon"
/>
<span>{{ error || helperText }}</span>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '~/components/ui/Icon.vue'
interface Props {
modelValue?: string
label: string
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'number'
variant?: 'glass' | 'solid' | 'outline'
leftIcon?: string
rightIcon?: string
error?: string
helperText?: string
required?: boolean
disabled?: boolean
readonly?: boolean
clearable?: boolean
autocomplete?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
variant: 'glass',
required: false,
disabled: false,
readonly: false,
clearable: false,
autocomplete: 'off'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'focus': []
'blur': []
'clear': []
}>()
const isFocused = ref(false)
const inputId = computed(() => `input-${Math.random().toString(36).substr(2, 9)}`)
const handleFocus = () => {
isFocused.value = true
emit('focus')
}
const handleBlur = () => {
isFocused.value = false
emit('blur')
}
const clearInput = () => {
emit('update:modelValue', '')
emit('clear')
}
</script>
<style scoped lang="scss">
.floating-input {
position: relative;
width: 100%;
&__wrapper {
position: relative;
display: flex;
align-items: center;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Base styles
.floating-input--glass & {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
&:hover:not(.floating-input--disabled &) {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(220, 38, 38, 0.2);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
}
.floating-input--solid & {
background: white;
border: 2px solid #e5e5e5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&:hover:not(.floating-input--disabled &) {
border-color: #d4d4d4;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.floating-input--outline & {
background: transparent;
border: 2px solid #d4d4d4;
&:hover:not(.floating-input--disabled &) {
border-color: #a3a3a3;
}
}
// Focus state
.floating-input--focused & {
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
transform: translateY(-1px);
}
.floating-input--focused.floating-input--glass & {
background: rgba(255, 255, 255, 0.9);
}
// Error state
.floating-input--error & {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
// Disabled state
.floating-input--disabled & {
opacity: 0.5;
cursor: not-allowed;
}
}
&__field {
flex: 1;
padding: 1.25rem 1rem 0.5rem;
background: transparent;
border: none;
outline: none;
font-size: 1rem;
color: #27272a;
transition: padding 0.2s ease;
&--with-left-icon {
padding-left: 3rem;
}
&--with-right-icon {
padding-right: 3rem;
}
&:disabled {
cursor: not-allowed;
}
// Remove autofill background
&:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px transparent inset;
-webkit-text-fill-color: #27272a;
transition: background-color 5000s ease-in-out 0s;
}
}
&__label {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 1rem;
color: #71717a;
pointer-events: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
padding: 0 0.25rem;
&--with-icon {
left: 3rem;
}
&--floating {
top: 0.75rem;
transform: translateY(0);
font-size: 0.75rem;
color: #dc2626;
font-weight: 500;
.floating-input--glass & {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.9) 50%,
transparent 50%,
transparent 100%
);
}
.floating-input--solid & {
background: linear-gradient(
to bottom,
white 0%,
white 50%,
transparent 50%,
transparent 100%
);
}
}
.floating-input--error &--floating {
color: #ef4444;
}
}
&__required {
color: #ef4444;
margin-left: 0.125rem;
}
&__icon {
position: absolute;
width: 1.25rem;
height: 1.25rem;
color: #dc2626;
&--left {
left: 1rem;
}
&--right {
right: 1rem;
}
}
&__clear {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
background: rgba(220, 38, 38, 0.1);
border: none;
border-radius: 50%;
color: #dc2626;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(220, 38, 38, 0.2);
transform: scale(1.1);
}
svg {
width: 0.875rem;
height: 0.875rem;
}
}
&__message {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #71717a;
.floating-input--error & {
color: #ef4444;
}
}
&__message-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
}
// Animations
.message-enter-active,
.message-leave-active {
transition: all 0.2s ease;
}
.message-enter-from {
opacity: 0;
transform: translateY(-4px);
}
.message-leave-to {
opacity: 0;
transform: translateY(-4px);
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.floating-input {
&__field {
color: white;
&:-webkit-autofill {
-webkit-text-fill-color: white;
}
}
&__wrapper {
.floating-input--glass & {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
.floating-input--solid & {
background: #27272a;
border-color: #3f3f46;
}
}
&__label {
color: #a3a3a3;
&--floating {
.floating-input--glass & {
background: linear-gradient(
to bottom,
rgba(30, 30, 30, 0.9) 0%,
rgba(30, 30, 30, 0.9) 50%,
transparent 50%,
transparent 100%
);
}
.floating-input--solid & {
background: linear-gradient(
to bottom,
#27272a 0%,
#27272a 50%,
transparent 50%,
transparent 100%
);
}
}
}
}
}
</style>

276
components/ui/GlassCard.vue Normal file
View File

@ -0,0 +1,276 @@
<template>
<div
v-motion
:initial="animated ? animationConfig.initial : {}"
:enter="animated ? animationConfig.enter : {}"
:hovered="hoverable ? { scale: 1.02 } : {}"
:delay="delay"
class="glass-card"
:class="[
`glass-card--${variant}`,
`glass-card--${size}`,
{
'glass-card--clickable': clickable,
'glass-card--elevated': elevated
}
]"
>
<div v-if="hasHeader" class="glass-card__header">
<slot name="header">
<h3 v-if="title" class="glass-card__title">{{ title }}</h3>
<p v-if="subtitle" class="glass-card__subtitle">{{ subtitle }}</p>
</slot>
</div>
<div class="glass-card__body">
<slot />
</div>
<div v-if="hasFooter" class="glass-card__footer">
<slot name="footer" />
</div>
<div v-if="gradient" class="glass-card__gradient"></div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
interface Props {
title?: string
subtitle?: string
variant?: 'light' | 'dark' | 'colored' | 'gradient'
size?: 'sm' | 'md' | 'lg' | 'xl'
clickable?: boolean
hoverable?: boolean
elevated?: boolean
gradient?: boolean
animated?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'light',
size: 'md',
clickable: false,
hoverable: true,
elevated: true,
gradient: false,
animated: true,
delay: 0
})
const slots = useSlots()
const hasHeader = computed(() => !!slots.header || props.title || props.subtitle)
const hasFooter = computed(() => !!slots.footer)
const animationConfig = {
initial: {
opacity: 0,
y: 20,
scale: 0.95
},
enter: {
opacity: 1,
y: 0,
scale: 1,
transition: {
type: 'spring',
stiffness: 200,
damping: 20
}
}
}
</script>
<style scoped lang="scss">
.glass-card {
position: relative;
border-radius: 20px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Glass effect base
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.1) 100%);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
// Variants
&--light {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #27272a;
}
&--dark {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
&--colored {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(185, 28, 28, 0.05) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(220, 38, 38, 0.2);
color: #27272a;
}
&--gradient {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.4) 100%);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.4);
color: #27272a;
}
// Sizes
&--sm {
.glass-card__body {
padding: 1rem;
}
.glass-card__header {
padding: 1rem 1rem 0.5rem;
}
.glass-card__footer {
padding: 0.5rem 1rem 1rem;
}
}
&--md {
.glass-card__body {
padding: 1.5rem;
}
.glass-card__header {
padding: 1.5rem 1.5rem 0.75rem;
}
.glass-card__footer {
padding: 0.75rem 1.5rem 1.5rem;
}
}
&--lg {
.glass-card__body {
padding: 2rem;
}
.glass-card__header {
padding: 2rem 2rem 1rem;
}
.glass-card__footer {
padding: 1rem 2rem 2rem;
}
}
&--xl {
.glass-card__body {
padding: 2.5rem;
}
.glass-card__header {
padding: 2.5rem 2.5rem 1.25rem;
}
.glass-card__footer {
padding: 1.25rem 2.5rem 2.5rem;
}
}
// States
&--clickable {
cursor: pointer;
&:active {
transform: scale(0.98);
}
}
&--elevated {
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.1),
0 2px 10px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover {
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.15),
0 4px 15px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
}
&:hover {
transform: translateY(-2px);
}
// Header
&__header {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
&__title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #dc2626;
}
&__subtitle {
font-size: 0.875rem;
margin: 0.25rem 0 0;
opacity: 0.8;
}
// Body
&__body {
position: relative;
z-index: 1;
}
// Footer
&__footer {
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
// Gradient overlay
&__gradient {
position: absolute;
top: 0;
right: 0;
width: 50%;
height: 100%;
background: linear-gradient(90deg,
transparent 0%,
rgba(220, 38, 38, 0.05) 100%);
pointer-events: none;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.glass-card--light {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
}
</style>

197
components/ui/Icon.vue Normal file
View File

@ -0,0 +1,197 @@
<template>
<component
:is="iconComponent"
v-if="iconComponent"
:size="size"
:stroke-width="strokeWidth"
:color="color"
class="lucide-icon"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import * as icons from 'lucide-vue-next'
interface Props {
name: string
size?: number | string
strokeWidth?: number
color?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 24,
strokeWidth: 2,
color: 'currentColor'
})
// Convert kebab-case to PascalCase for icon component names
const toPascalCase = (str: string) => {
return str
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
}
const iconComponent = computed(() => {
// Handle special cases and common mappings
const iconMap: Record<string, string> = {
'alert-circle': 'AlertCircle',
'chevron-down': 'ChevronDown',
'chevron-up': 'ChevronUp',
'x': 'X',
'check': 'Check',
'trending-up': 'TrendingUp',
'trending-down': 'TrendingDown',
'minus': 'Minus',
'search': 'Search',
'filter': 'Filter',
'calendar': 'Calendar',
'map-pin': 'MapPin',
'users': 'Users',
'clock': 'Clock',
'star': 'Star',
'grid': 'Grid',
'list': 'List',
'plus': 'Plus',
'user': 'User',
'mail': 'Mail',
'phone': 'Phone',
'globe': 'Globe',
'briefcase': 'Briefcase',
'building': 'Building',
'award': 'Award',
'shield': 'Shield',
'heart': 'Heart',
'edit': 'Edit',
'settings': 'Settings',
'log-out': 'LogOut',
'bell': 'Bell',
'home': 'Home',
'activity': 'Activity',
'message-square': 'MessageSquare',
'arrow-right': 'ArrowRight',
'external-link': 'ExternalLink',
'download': 'Download',
'upload': 'Upload',
'share': 'Share',
'copy': 'Copy',
'trash': 'Trash',
'eye': 'Eye',
'eye-off': 'EyeOff',
'lock': 'Lock',
'unlock': 'Unlock',
'camera': 'Camera',
'image': 'Image',
'video': 'Video',
'file-text': 'FileText',
'bar-chart': 'BarChart',
'pie-chart': 'PieChart',
'dollar-sign': 'DollarSign',
'credit-card': 'CreditCard',
'gift': 'Gift',
'bookmark': 'Bookmark',
'tag': 'Tag',
'folder': 'Folder',
'layers': 'Layers',
'zap': 'Zap',
'sun': 'Sun',
'moon': 'Moon',
'more-horizontal': 'MoreHorizontal',
'more-vertical': 'MoreVertical',
'menu': 'Menu',
'arrow-left': 'ArrowLeft',
'arrow-up': 'ArrowUp',
'arrow-down': 'ArrowDown',
'chevron-left': 'ChevronLeft',
'chevron-right': 'ChevronRight',
'check-circle': 'CheckCircle',
'x-circle': 'XCircle',
'alert-triangle': 'AlertTriangle',
'info': 'Info',
'help-circle': 'HelpCircle',
'loader': 'Loader',
'refresh-cw': 'RefreshCw',
'link': 'Link',
'paperclip': 'Paperclip',
'send': 'Send',
'inbox': 'Inbox',
'archive': 'Archive',
'flag': 'Flag',
'save': 'Save',
'wifi': 'Wifi',
'wifi-off': 'WifiOff',
'mic': 'Mic',
'mic-off': 'MicOff',
'volume': 'Volume',
'volume-x': 'VolumeX',
'play': 'Play',
'pause': 'Pause',
'skip-forward': 'SkipForward',
'skip-back': 'SkipBack',
'maximize': 'Maximize',
'minimize': 'Minimize',
'expand': 'Expand',
'compass': 'Compass',
'map': 'Map',
'navigation': 'Navigation',
'target': 'Target',
'crown': 'Crown',
'key': 'Key',
'code': 'Code',
'terminal': 'Terminal',
'database': 'Database',
'server': 'Server',
'cpu': 'Cpu',
'hard-drive': 'HardDrive',
'monitor': 'Monitor',
'smartphone': 'Smartphone',
'tablet': 'Tablet',
'watch': 'Watch',
'printer': 'Printer',
'headphones': 'Headphones',
'bluetooth': 'Bluetooth',
'battery': 'Battery',
'battery-charging': 'BatteryCharging',
'clipboard': 'Clipboard',
'hash': 'Hash',
'at-sign': 'AtSign',
'percent': 'Percent',
'thumbs-up': 'ThumbsUp',
'thumbs-down': 'ThumbsDown',
'smile': 'Smile',
'frown': 'Frown',
'coffee': 'Coffee',
'shopping-cart': 'ShoppingCart',
'shopping-bag': 'ShoppingBag',
'package': 'Package',
'truck': 'Truck',
'book': 'Book',
'book-open': 'BookOpen',
'feather': 'Feather',
'sliders': 'Sliders',
'toggle-left': 'ToggleLeft',
'toggle-right': 'ToggleRight',
'power': 'Power',
'log-in': 'LogIn',
'circle': 'Circle',
'square': 'Square',
'triangle': 'Triangle'
}
// Get the icon name from the map or convert from kebab-case
const iconName = iconMap[props.name] || toPascalCase(props.name)
// Return the icon component from lucide-vue-next
return (icons as any)[iconName] || (icons as any)[iconName + 'Icon'] || null
})
</script>
<style scoped>
.lucide-icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,408 @@
<template>
<div
v-motion
:initial="{ opacity: 0, scale: 0.95 }"
:enter="{
opacity: 1,
scale: 1,
transition: {
delay: delay * 50,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
:hovered="{ scale: 1.02 }"
class="member-card"
:class="[
`member-card--${variant}`,
{ 'member-card--featured': featured }
]"
@click="$emit('click', member)"
>
<div class="member-card__header">
<div class="member-card__avatar">
<img
v-if="member.avatar"
:src="member.avatar"
:alt="member.name"
@error="handleImageError"
/>
<div v-else class="member-card__avatar-placeholder">
{{ initials }}
</div>
<div
v-if="member.status === 'online'"
class="member-card__status-indicator"
/>
</div>
<div v-if="member.role" class="member-card__role">
{{ member.role }}
</div>
</div>
<div class="member-card__body">
<h3 class="member-card__name">{{ member.name }}</h3>
<p v-if="member.title" class="member-card__title">{{ member.title }}</p>
<p v-if="member.company" class="member-card__company">{{ member.company }}</p>
<div v-if="member.tags && member.tags.length" class="member-card__tags">
<span
v-for="tag in member.tags.slice(0, 3)"
:key="tag"
class="member-card__tag"
>
{{ tag }}
</span>
<span
v-if="member.tags.length > 3"
class="member-card__tag member-card__tag--more"
>
+{{ member.tags.length - 3 }}
</span>
</div>
</div>
<div class="member-card__footer">
<div class="member-card__stats">
<div v-if="member.joinDate" class="member-card__stat">
<span class="member-card__stat-label">Member Since</span>
<span class="member-card__stat-value">{{ member.joinDate }}</span>
</div>
<div v-if="member.connections !== undefined" class="member-card__stat">
<span class="member-card__stat-label">Connections</span>
<span class="member-card__stat-value">{{ member.connections }}</span>
</div>
</div>
<div class="member-card__actions">
<button
class="member-card__action"
@click.stop="$emit('connect', member)"
>
<span>{{ member.connected ? '✓' : '+' }}</span>
{{ member.connected ? 'Connected' : 'Connect' }}
</button>
<button
class="member-card__action member-card__action--secondary"
@click.stop="$emit('message', member)"
>
<span></span>
Message
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
interface Member {
id: string | number
name: string
avatar?: string
title?: string
company?: string
role?: string
status?: 'online' | 'offline' | 'away'
tags?: string[]
joinDate?: string
connections?: number
connected?: boolean
}
interface Props {
member: Member
variant?: 'glass' | 'solid' | 'outline'
featured?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
featured: false,
delay: 0
})
defineEmits<{
click: [member: Member]
connect: [member: Member]
message: [member: Member]
}>()
const initials = computed(() => {
const names = props.member.name.split(' ')
return names.map(n => n[0]).join('').toUpperCase().slice(0, 2)
})
const handleImageError = (e: Event) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
}
</script>
<style scoped lang="scss">
.member-card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// Glass variant
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
&:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
}
// Solid variant
&--solid {
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-4px);
}
}
// Outline variant
&--outline {
background: transparent;
border: 2px solid rgba(220, 38, 38, 0.2);
&:hover {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.3);
transform: translateY(-4px);
}
}
// Featured state
&--featured {
border: 2px solid #dc2626;
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.15);
&::before {
content: '⭐';
position: absolute;
top: -0.5rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: #dc2626;
border-radius: 50%;
font-size: 1rem;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
&__avatar {
position: relative;
width: 4rem;
height: 4rem;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 1.5rem;
font-weight: 600;
color: #dc2626;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
}
&__status-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 1rem;
height: 1rem;
background: #10b981;
border: 2px solid white;
border-radius: 50%;
}
&__role {
padding: 0.25rem 0.75rem;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
&__body {
flex: 1;
margin-bottom: 1rem;
}
&__name {
margin: 0 0 0.25rem;
font-size: 1.125rem;
font-weight: 600;
color: #27272a;
}
&__title {
margin: 0 0 0.125rem;
font-size: 0.875rem;
font-weight: 500;
color: #dc2626;
}
&__company {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
&__tag {
padding: 0.25rem 0.5rem;
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
&--more {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
}
&__footer {
padding-top: 1rem;
border-top: 1px solid rgba(220, 38, 38, 0.1);
}
&__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
&__stat {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__stat-label {
font-size: 0.75rem;
color: #6b7280;
}
&__stat-value {
font-size: 0.875rem;
font-weight: 600;
color: #27272a;
}
&__actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
&__action {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
background: #dc2626;
color: white;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
span {
font-size: 0.875rem;
}
&:hover {
background: #b91c1c;
transform: translateY(-1px);
}
&--secondary {
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
&:hover {
background: rgba(220, 38, 38, 0.2);
}
}
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.member-card {
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
&--solid {
background: #27272a;
}
&__name {
color: white;
}
&__stat-value {
color: #e5e5e5;
}
}
}
</style>

View File

@ -0,0 +1,329 @@
<template>
<button
v-motion
:initial="animated ? { scale: 0.95, opacity: 0 } : {}"
:enter="animated ? { scale: 1, opacity: 1 } : {}"
:hovered="hoverable ? { scale: 1.05 } : {}"
:tapped="{ scale: 0.95 }"
:delay="delay"
class="monaco-button"
:class="[
`monaco-button--${variant}`,
`monaco-button--${size}`,
{
'monaco-button--block': block,
'monaco-button--loading': loading,
'monaco-button--icon-only': !$slots.default && icon
}
]"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<span v-if="loading" class="monaco-button__spinner">
<svg class="monaco-button__spinner-svg" viewBox="0 0 24 24">
<circle
class="monaco-button__spinner-circle"
cx="12"
cy="12"
r="10"
stroke-width="3"
fill="none"
/>
</svg>
</span>
<Icon
v-if="icon && !loading"
:name="icon"
class="monaco-button__icon"
:class="{ 'monaco-button__icon--left': $slots.default }"
/>
<span v-if="$slots.default" class="monaco-button__content">
<slot />
</span>
<Icon
v-if="rightIcon && !loading"
:name="rightIcon"
class="monaco-button__icon monaco-button__icon--right"
/>
</button>
</template>
<script setup lang="ts">
import Icon from '~/components/ui/Icon.vue'
interface Props {
variant?: 'primary' | 'secondary' | 'glass' | 'gradient' | 'outline' | 'ghost'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
icon?: string
rightIcon?: string
block?: boolean
disabled?: boolean
loading?: boolean
hoverable?: boolean
animated?: boolean
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
block: false,
disabled: false,
loading: false,
hoverable: true,
animated: true,
delay: 0
})
defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<style scoped lang="scss">
.monaco-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
border: none;
overflow: hidden;
// Create shimmer effect for gradient variant
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: left 0.5s;
}
&:hover::before {
left: 100%;
}
// Variants
&--primary {
background: #dc2626;
color: white;
box-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
&:hover:not(:disabled) {
background: #b91c1c;
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.35);
transform: translateY(-2px);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
&--secondary {
background: white;
color: #dc2626;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
&:hover:not(:disabled) {
background: #fef2f2;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
}
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #dc2626;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(220, 38, 38, 0.2);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
}
&--gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
color: white;
box-shadow: 0 4px 14px rgba(220, 38, 38, 0.25);
&:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.35);
transform: translateY(-2px);
}
}
&--outline {
background: transparent;
color: #dc2626;
border: 2px solid #dc2626;
&:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.1);
border-color: #b91c1c;
transform: translateY(-2px);
}
}
&--ghost {
background: transparent;
color: #dc2626;
&:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.1);
transform: translateY(-2px);
}
}
// Sizes
&--xs {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 8px;
}
&--sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: 10px;
}
&--md {
padding: 0.5rem 1rem;
font-size: 1rem;
}
&--lg {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
border-radius: 14px;
}
&--xl {
padding: 1rem 2rem;
font-size: 1.25rem;
border-radius: 16px;
}
// States
&--block {
width: 100%;
}
&--icon-only {
aspect-ratio: 1;
padding: 0;
&.monaco-button--xs { width: 1.75rem; }
&.monaco-button--sm { width: 2rem; }
&.monaco-button--md { width: 2.5rem; }
&.monaco-button--lg { width: 3rem; }
&.monaco-button--xl { width: 3.5rem; }
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--loading {
color: transparent;
pointer-events: none;
}
// Icons
&__icon {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
&--left {
margin-right: 0.25rem;
}
&--right {
margin-left: 0.25rem;
}
}
// Spinner
&__spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__spinner-svg {
width: 1.5em;
height: 1.5em;
animation: spin 1s linear infinite;
}
&__spinner-circle {
stroke: currentColor;
stroke-linecap: round;
stroke-dasharray: 64;
stroke-dashoffset: 64;
animation: dash 1.5s ease-in-out infinite;
}
}
// Animations
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes dash {
0% { stroke-dashoffset: 64; }
50% { stroke-dashoffset: 16; }
100% { stroke-dashoffset: 64; }
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.monaco-button {
&--secondary {
background: #27272a;
color: #dc2626;
&:hover:not(:disabled) {
background: #3f3f46;
}
}
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
}
}
</style>

369
components/ui/StatsCard.vue Normal file
View File

@ -0,0 +1,369 @@
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{
opacity: 1,
y: 0,
transition: {
delay: delay * 100,
type: 'spring',
stiffness: 200,
damping: 20
}
}"
class="stats-card"
:class="[
`stats-card--${variant}`,
{ 'stats-card--clickable': clickable }
]"
@click="$emit('click')"
>
<div class="stats-card__header">
<div class="stats-card__icon-wrapper">
<Icon
:name="icon"
class="stats-card__icon"
/>
</div>
<div v-if="trend" class="stats-card__trend" :class="`stats-card__trend--${trend.type}`">
<Icon
:name="trend.type === 'up' ? 'trending-up' : trend.type === 'down' ? 'trending-down' : 'minus'"
class="stats-card__trend-icon"
/>
<span>{{ trend.value }}%</span>
</div>
</div>
<div class="stats-card__content">
<h3 class="stats-card__label">{{ label }}</h3>
<div class="stats-card__value-wrapper">
<span v-if="prefix" class="stats-card__prefix">{{ prefix }}</span>
<AnimatedNumber
:value="value"
:duration="1500"
:format="format"
class="stats-card__value"
/>
<span v-if="suffix" class="stats-card__suffix">{{ suffix }}</span>
</div>
<p v-if="description" class="stats-card__description">{{ description }}</p>
</div>
<div v-if="progress !== undefined" class="stats-card__progress">
<div class="stats-card__progress-bar">
<div
class="stats-card__progress-fill"
:style="{ width: `${Math.min(100, Math.max(0, progress))}%` }"
/>
</div>
<span class="stats-card__progress-label">{{ progress }}% Complete</span>
</div>
<div v-if="sparkline" class="stats-card__sparkline">
<svg
viewBox="0 0 100 40"
preserveAspectRatio="none"
class="stats-card__sparkline-svg"
>
<polyline
:points="sparklinePoints"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
:points="`${sparklinePoints} 100,40 0,40`"
fill="currentColor"
fill-opacity="0.1"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '~/components/ui/Icon.vue'
import AnimatedNumber from '~/components/ui/AnimatedNumber.vue'
interface Trend {
type: 'up' | 'down' | 'neutral'
value: number
}
interface Props {
label: string
value: number
icon: string
variant?: 'glass' | 'solid' | 'gradient' | 'outline'
prefix?: string
suffix?: string
description?: string
trend?: Trend
progress?: number
sparkline?: number[]
clickable?: boolean
format?: (value: number) => string
delay?: number
}
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
clickable: false,
delay: 0,
format: (value: number) => value.toLocaleString()
})
defineEmits<{
click: []
}>()
const sparklinePoints = computed(() => {
if (!props.sparkline || props.sparkline.length === 0) return ''
const data = props.sparkline
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
return data
.map((value, index) => {
const x = (index / (data.length - 1)) * 100
const y = 40 - ((value - min) / range) * 35
return `${x},${y}`
})
.join(' ')
})
</script>
<style scoped lang="scss">
.stats-card {
position: relative;
padding: 1.5rem;
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
// Glass variant
&--glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
&:hover {
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transform: translateY(-2px);
}
}
// Solid variant
&--solid {
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
&:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
}
// Gradient variant
&--gradient {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(220, 38, 38, 0.02) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(220, 38, 38, 0.1);
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.08);
&:hover {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.08) 0%,
rgba(220, 38, 38, 0.03) 100%);
transform: translateY(-2px);
}
}
// Outline variant
&--outline {
background: transparent;
border: 2px solid rgba(220, 38, 38, 0.2);
&:hover {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.3);
transform: translateY(-2px);
}
}
&--clickable {
cursor: pointer;
}
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
}
&__icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.1) 0%,
rgba(220, 38, 38, 0.05) 100%);
border-radius: 12px;
}
&__icon {
width: 1.5rem;
height: 1.5rem;
color: #dc2626;
}
&__trend {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
&--up {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
&--down {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
&--neutral {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
}
&__trend-icon {
width: 1rem;
height: 1rem;
}
&__content {
margin-bottom: 1rem;
}
&__label {
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
margin: 0 0 0.5rem;
}
&__value-wrapper {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__value {
font-size: 2rem;
font-weight: 700;
color: #27272a;
line-height: 1;
}
&__prefix,
&__suffix {
font-size: 1.25rem;
font-weight: 500;
color: #6b7280;
}
&__description {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
&__progress {
margin-top: 1rem;
}
&__progress-bar {
height: 6px;
background: rgba(220, 38, 38, 0.1);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
&__progress-fill {
height: 100%;
background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%);
border-radius: 3px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
}
&__progress-label {
font-size: 0.75rem;
color: #6b7280;
}
&__sparkline {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
opacity: 0.5;
}
&__sparkline-svg {
width: 100%;
height: 100%;
color: #dc2626;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.stats-card {
&--glass {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
}
&--solid {
background: #27272a;
}
&__value {
color: white;
}
&__label,
&__description,
&__progress-label {
color: #a3a3a3;
}
}
}
</style>

326
composables/useAuth.ts Normal file
View File

@ -0,0 +1,326 @@
import type { User } from '~/utils/types';
export const useAuth = () => {
// Use useState for SSR compatibility - prevents hydration mismatches
const user = useState<User | null>('auth.user', () => null);
const isAuthenticated = computed(() => !!user.value);
const loading = ref(false);
const error = ref<string | null>(null);
// Enhanced role checking method - supports both realm roles and legacy groups
const hasRole = (roleName: string): boolean => {
if (!user.value) return false;
// Get roles from user token (Keycloak format)
const userToken = user.value as any; // Cast for accessing token properties
// Check realm roles first (new system)
const realmRoles = userToken.realm_access?.roles || [];
if (realmRoles.includes(roleName)) {
return true;
}
// Check client roles (new system)
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const roles = clientRoles[clientId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
// Fallback to legacy group system
const groups = user.value.groups || [];
return groups.includes(roleName) || groups.includes(`/${roleName}`);
};
// Enhanced tier-based computed properties with role support
const isUser = computed(() => {
// Check new realm roles first
if (hasRole('monaco-user')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'user';
});
// Alias for consistency with new naming convention
const isMember = isUser;
const isBoard = computed(() => {
// Check new realm roles first
if (hasRole('monaco-board')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'board';
});
const isAdmin = computed(() => {
// Check new realm roles first
if (hasRole('monaco-admin')) return true;
// Fallback to legacy tier system
return user.value?.tier === 'admin';
});
// Enhanced tier computation with role priority
const userTier = computed(() => {
if (hasRole('monaco-admin')) return 'admin';
if (hasRole('monaco-board')) return 'board';
if (hasRole('monaco-user')) return 'user';
// Fallback to legacy tier system
return user.value?.tier || 'user';
});
const firstName = computed(() => {
if (user.value?.firstName) return user.value.firstName;
if (user.value?.name) return user.value.name.split(' ')[0];
return 'User';
});
// Enhanced helper methods
const hasTier = (requiredTier: 'user' | 'board' | 'admin') => {
// Use computed userTier which handles both new and legacy systems
return userTier.value === requiredTier;
};
const hasGroup = (groupName: string) => {
return user.value?.groups?.includes(groupName) || false;
};
// New helper methods for realm roles
const hasRealmRole = (roleName: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const realmRoles = userToken.realm_access?.roles || [];
return realmRoles.includes(roleName);
};
const hasClientRole = (roleName: string, clientId?: string): boolean => {
if (!user.value) return false;
const userToken = user.value as any;
const clientRoles = userToken.resource_access || {};
if (clientId) {
// Check specific client
const roles = clientRoles[clientId]?.roles || [];
return roles.includes(roleName);
} else {
// Check all clients
for (const cId in clientRoles) {
const roles = clientRoles[cId]?.roles || [];
if (roles.includes(roleName)) {
return true;
}
}
return false;
}
};
// Get all user roles (combines realm and client roles)
const getAllRoles = (): string[] => {
if (!user.value) return [];
const userToken = user.value as any;
const roles: string[] = [];
// Add realm roles
const realmRoles = userToken.realm_access?.roles || [];
roles.push(...realmRoles);
// Add client roles
const clientRoles = userToken.resource_access || {};
for (const clientId in clientRoles) {
const clientRolesList = clientRoles[clientId]?.roles || [];
roles.push(...clientRolesList);
}
// Add legacy groups for compatibility
const groups = user.value.groups || [];
roles.push(...groups);
return [...new Set(roles)]; // Remove duplicates
};
// Direct login method
const login = async (credentials: { username: string; password: string; rememberMe?: boolean }) => {
loading.value = true;
error.value = null;
try {
console.log('🔄 Starting login request...');
const response = await $fetch<{
success: boolean;
redirectTo?: string;
user?: User;
}>('/api/auth/direct-login', {
method: 'POST',
body: credentials,
timeout: 30000 // 30 second timeout
});
console.log('✅ Login response received:', response);
if (response.success) {
// Add a small delay to ensure cookie is set before checking session
console.log('⏳ Waiting for cookie to be set...');
await new Promise(resolve => setTimeout(resolve, 200));
// After successful login, get the user data from the session
console.log('🔄 Getting user data from session...');
// Try multiple times in case of timing issues
let sessionSuccess = false;
let attempts = 0;
const maxAttempts = 3;
while (!sessionSuccess && attempts < maxAttempts) {
attempts++;
console.log(`🔄 Session check attempt ${attempts}/${maxAttempts}`);
sessionSuccess = await checkAuth();
if (!sessionSuccess && attempts < maxAttempts) {
console.log('⏳ Session not ready, waiting 500ms...');
await new Promise(resolve => setTimeout(resolve, 500));
}
}
if (sessionSuccess) {
console.log('👤 User data retrieved from session:', user.value);
// Return redirect URL for the component to handle
console.log('✅ Login successful, returning redirect URL:', response.redirectTo || '/dashboard');
return {
success: true,
redirectTo: response.redirectTo || '/dashboard'
};
} else {
console.warn('❌ Failed to get user data from session after login');
// Still return success with redirect since login was successful on server
return {
success: true,
redirectTo: '/dashboard'
};
}
}
console.warn('❌ Login response indicates failure:', response);
return { success: false, error: 'Login failed' };
} catch (err: any) {
console.error('❌ Login error caught:', err);
// Handle different types of errors
let errorMessage = 'Login failed';
if (err.status === 502) {
errorMessage = 'Server temporarily unavailable. Please try again.';
} else if (err.status === 401) {
errorMessage = 'Invalid username or password';
} else if (err.status === 429) {
errorMessage = 'Too many login attempts. Please try again later.';
} else if (err.data?.message) {
errorMessage = err.data.message;
} else if (err.message) {
errorMessage = err.message;
}
error.value = errorMessage;
return { success: false, error: errorMessage };
} finally {
loading.value = false;
}
};
// OAuth login method (fallback)
const loginOAuth = () => {
return navigateTo('/api/auth/login');
};
// Password reset method
const requestPasswordReset = async (email: string) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{
success: boolean;
message: string;
}>('/api/auth/forgot-password', {
method: 'POST',
body: { email }
});
return { success: true, message: response.message };
} catch (err: any) {
error.value = err.data?.message || 'Password reset failed';
return { success: false, error: error.value };
} finally {
loading.value = false;
}
};
// Check authentication status - simple and reliable
const checkAuth = async () => {
try {
console.log('🔄 Performing session check...');
const response = await $fetch<{
authenticated: boolean;
user: User | null;
}>('/api/auth/session');
if (response.authenticated && response.user) {
user.value = response.user;
return true;
} else {
user.value = null;
return false;
}
} catch (err) {
console.error('Auth check error:', err);
user.value = null;
return false;
}
};
// Logout method
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
user.value = null;
await navigateTo('/login');
} catch (err) {
console.error('Logout error:', err);
user.value = null;
await navigateTo('/login');
}
};
return {
// State
user: readonly(user),
isAuthenticated,
loading: readonly(loading),
error: readonly(error),
// Tier-based properties
userTier,
isUser,
isMember, // Alias for isUser, better naming convention
isBoard,
isAdmin,
firstName,
// Helper methods
hasTier,
hasGroup,
hasRole, // Enhanced with realm role support
hasRealmRole,
hasClientRole,
getAllRoles,
// Actions
login,
loginOAuth,
logout,
requestPasswordReset,
checkAuth,
};
};

441
composables/useEvents.ts Normal file
View File

@ -0,0 +1,441 @@
// composables/useEvents.ts
import type { Event, EventsResponse, EventFilters, EventCreateRequest, EventRSVPRequest } from '~/utils/types';
export const useEvents = () => {
const events = ref<Event[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const upcomingEvent = ref<Event | null>(null);
const cache = reactive<Map<string, { data: Event[]; timestamp: number }>>(new Map());
const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// Get authenticated user info
const { user, userTier } = useAuth();
/**
* Fetch events with optional filtering and caching
*/
const fetchEvents = async (filters?: EventFilters & { force?: boolean }) => {
loading.value = true;
error.value = null;
try {
// Create cache key
const cacheKey = JSON.stringify(filters || {});
const cached = cache.get(cacheKey);
// Check cache if not forcing refresh
if (!filters?.force && cached) {
const now = Date.now();
if (now - cached.timestamp < CACHE_TIMEOUT) {
events.value = cached.data;
loading.value = false;
return cached.data;
}
}
// Default date range (current month + 2 months ahead)
const defaultFilters: EventFilters = {
start_date: startOfMonth(new Date()).toISOString(),
end_date: endOfMonth(addMonths(new Date(), 2)).toISOString(),
user_role: userTier.value,
...filters
};
const response = await $fetch<EventsResponse>('/api/events', {
query: {
...defaultFilters,
calendar_format: 'false'
}
});
if (response.success) {
events.value = response.data;
// Cache the results
cache.set(cacheKey, {
data: response.data,
timestamp: Date.now()
});
// Update upcoming event
updateUpcomingEvent(response.data);
return response.data;
} else {
throw new Error(response.message || 'Failed to fetch events');
}
} catch (err: any) {
error.value = err.message || 'Failed to load events';
console.error('Error fetching events:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Create a new event (board/admin only)
*/
const createEvent = async (eventData: EventCreateRequest) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; data: Event; message: string }>('/api/events', {
method: 'POST',
body: eventData
});
if (response.success) {
// Clear cache and refresh events
cache.clear();
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to create event');
}
} catch (err: any) {
error.value = err.message || 'Failed to create event';
console.error('Error creating event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* RSVP to an event with support for guests and real-time updates
*/
const rsvpToEvent = async (eventId: string, rsvpData: Omit<EventRSVPRequest, 'event_id'> & { extra_guests?: string }) => {
loading.value = true;
error.value = null;
try {
console.log('[useEvents] RSVP to event:', eventId, 'with data:', rsvpData);
const response = await $fetch<{ success: boolean; data: any; message: string }>(`/api/events/${eventId}/rsvp`, {
method: 'POST',
body: {
...rsvpData,
event_id: eventId,
member_id: user.value?.id || ''
}
});
if (response.success) {
// Find event by event_id first, then fallback to database ID
let eventIndex = events.value.findIndex(e => e.event_id === eventId);
if (eventIndex === -1) {
eventIndex = events.value.findIndex(e => (e as any).Id === eventId || e.id === eventId);
}
console.log('[useEvents] Event found at index:', eventIndex, 'using event_id:', eventId);
if (eventIndex !== -1) {
const event = events.value[eventIndex];
// Update RSVP status
event.user_rsvp = response.data;
// Calculate attendee count including guests
if (rsvpData.rsvp_status === 'confirmed') {
const currentCount = parseInt(event.current_attendees || '0');
const guestCount = parseInt(rsvpData.extra_guests || '0');
const totalAdded = 1 + guestCount; // Member + guests
event.current_attendees = (currentCount + totalAdded).toString();
console.log('[useEvents] Updated attendee count:', {
previous: currentCount,
added: totalAdded,
new: event.current_attendees,
guests: guestCount
});
}
// Trigger reactivity
events.value[eventIndex] = { ...event };
}
// Clear cache for fresh data on next load
cache.clear();
// Force refresh events data to ensure accuracy
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to RSVP');
}
} catch (err: any) {
error.value = err.message || 'Failed to RSVP to event';
console.error('Error RSVPing to event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Cancel RSVP to an event
*/
const cancelRSVP = async (eventId: string) => {
loading.value = true;
error.value = null;
try {
// Find the event to get current RSVP info
let event = events.value.find(e => e.event_id === eventId);
if (!event) {
event = events.value.find(e => (e as any).Id === eventId || e.id === eventId);
}
if (!event?.user_rsvp) {
throw new Error('No RSVP found to cancel');
}
const response = await $fetch<{ success: boolean; data: any; message: string }>(`/api/events/${eventId}/rsvp`, {
method: 'DELETE'
});
if (response.success) {
const eventIndex = events.value.findIndex(e => e === event);
if (eventIndex !== -1) {
const currentCount = parseInt(events.value[eventIndex].current_attendees || '0');
const guestCount = parseInt(events.value[eventIndex].user_rsvp?.extra_guests || '0');
const totalRemoved = 1 + guestCount; // Member + guests
// Update attendee count and remove RSVP
events.value[eventIndex].current_attendees = Math.max(0, currentCount - totalRemoved).toString();
events.value[eventIndex].user_rsvp = undefined;
// Trigger reactivity
events.value[eventIndex] = { ...events.value[eventIndex] };
}
// Clear cache and refresh
cache.clear();
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to cancel RSVP');
}
} catch (err: any) {
error.value = err.message || 'Failed to cancel RSVP';
console.error('Error canceling RSVP:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Update attendance for an event (board/admin only)
*/
const updateAttendance = async (eventId: string, memberId: string, attended: boolean) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; data?: any; message: string }>(`/api/events/${eventId}/attendees`, {
method: 'PATCH',
body: {
event_id: eventId,
member_id: memberId,
attended
}
});
if (response.success) {
// Update local event data
const eventIndex = events.value.findIndex(e => e.id === eventId);
if (eventIndex !== -1 && events.value[eventIndex].attendee_list) {
const attendeeIndex = events.value[eventIndex].attendee_list!.findIndex(
a => a.member_id === memberId
);
if (attendeeIndex !== -1) {
events.value[eventIndex].attendee_list![attendeeIndex].attended = attended ? 'true' : 'false';
}
}
// Return data if available, otherwise return success status
return response.data || { success: true, message: response.message };
} else {
throw new Error(response.message || 'Failed to update attendance');
}
} catch (err: any) {
error.value = err.message || 'Failed to update attendance';
console.error('Error updating attendance:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Get events for calendar display
*/
const getCalendarEvents = async (start: string, end: string) => {
try {
const response = await $fetch<EventsResponse>('/api/events', {
query: {
start_date: start,
end_date: end,
user_role: userTier.value,
calendar_format: 'true'
}
});
if (response.success) {
return response.data;
}
return [];
} catch (err) {
console.error('Error fetching calendar events:', err);
return [];
}
};
/**
* Get upcoming events for banners/widgets
*/
const getUpcomingEvents = (limit = 5): Event[] => {
const now = new Date();
return events.value
.filter(event => new Date(event.start_datetime) >= now)
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime())
.slice(0, limit);
};
/**
* Find event by ID
*/
const findEventById = (eventId: string): Event | undefined => {
return events.value.find(event => event.id === eventId);
};
/**
* Check if user has RSVP'd to an event
*/
const hasUserRSVP = (eventId: string): boolean => {
const event = findEventById(eventId);
return !!event?.user_rsvp;
};
/**
* Get user's RSVP status for an event
*/
const getUserRSVPStatus = (eventId: string): string | null => {
const event = findEventById(eventId);
return event?.user_rsvp?.rsvp_status || null;
};
/**
* Update the upcoming event reference
*/
const updateUpcomingEvent = (eventList: Event[]) => {
const upcoming = eventList
.filter(event => new Date(event.start_datetime) >= new Date())
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime());
upcomingEvent.value = upcoming.length > 0 ? upcoming[0] : null;
};
/**
* Clear cache manually
*/
const clearCache = () => {
cache.clear();
};
/**
* Delete an event (board/admin only)
*/
const deleteEvent = async (eventId: string) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; message: string; deleted: any }>(`/api/events/${eventId}`, {
method: 'DELETE'
});
if (response.success) {
// Remove event from local state
const eventIndex = events.value.findIndex(e =>
e.event_id === eventId ||
e.id === eventId ||
(e as any).Id === eventId
);
if (eventIndex !== -1) {
events.value.splice(eventIndex, 1);
}
// Clear cache and refresh
clearCache();
await fetchEvents({ force: true });
return response;
} else {
throw new Error(response.message || 'Failed to delete event');
}
} catch (err: any) {
error.value = err.message || 'Failed to delete event';
console.error('Error deleting event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Refresh events data
*/
const refreshEvents = async () => {
clearCache();
return await fetchEvents({ force: true });
};
// Utility functions for date handling
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function endOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
return {
// Reactive state
events,
loading,
error,
upcomingEvent,
// Methods
fetchEvents,
createEvent,
deleteEvent,
rsvpToEvent,
cancelRSVP,
updateAttendance,
getCalendarEvents,
getUpcomingEvents,
findEventById,
hasUserRSVP,
getUserRSVPStatus,
clearCache,
refreshEvents
};
};

339
deploy.sh
View File

@ -1,339 +0,0 @@
#!/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

View File

View File

View File

@ -1,386 +0,0 @@
# 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

View File

@ -1,440 +0,0 @@
# 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

View File

@ -1,318 +1,79 @@
# Monaco USA Portal - Full Stack Docker Compose version: '3.8'
# Includes: PostgreSQL, Supabase Services, and SvelteKit App
services: services:
# ============================================ monacousa-portal:
# 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: build:
context: . context: .
dockerfile: Dockerfile 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 container_name: monacousa-portal
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORTAL_PORT:-7453}:3000" - "6060:6060"
volumes:
# Volume for persistent data (environment files, logs, etc.)
- ./data:/app/data
# Optional: Mount logs directory
- ./logs:/app/logs
environment: environment:
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455} # Basic configuration
PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} - NODE_ENV=production
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} - NUXT_HOST=0.0.0.0
SUPABASE_INTERNAL_URL: http://kong:8000 - NUXT_PORT=6060
NODE_ENV: production
ORIGIN: http://localhost:7453 # Keycloak Configuration (override with your values)
# Body size limit for file uploads (50MB) - NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-https://auth.monacousa.org/realms/monacousa-portal}
BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800} - NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-monacousa-portal}
depends_on: - NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
kong: - NUXT_KEYCLOAK_CALLBACK_URL=${KEYCLOAK_CALLBACK_URL:-https://monacousa.org/auth/callback}
condition: service_started
db: # NocoDB Configuration
condition: service_healthy - NUXT_NOCODB_URL=${NOCODB_URL}
- NUXT_NOCODB_TOKEN=${NOCODB_TOKEN}
- NUXT_NOCODB_BASE_ID=${NOCODB_BASE_ID}
# MinIO Configuration
- NUXT_MINIO_ENDPOINT=${MINIO_ENDPOINT:-s3.monacousa.org}
- NUXT_MINIO_PORT=${MINIO_PORT:-443}
- NUXT_MINIO_USE_SSL=${MINIO_USE_SSL:-true}
- NUXT_MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- NUXT_MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- NUXT_MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME:-monacousa-portal}
# Security Configuration
- NUXT_SESSION_SECRET=${SESSION_SECRET}
- NUXT_ENCRYPTION_KEY=${ENCRYPTION_KEY}
# Public Configuration
- NUXT_PUBLIC_DOMAIN=${PUBLIC_DOMAIN:-monacousa.org}
# Optional: Wait for services
- WAIT_FOR_SERVICES=${WAIT_FOR_SERVICES:-false}
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:6060/api/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks: networks:
- monacousa-network - monacousa-network
# Resource limits (adjust as needed)
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
# ============================================
# Networks
# ============================================
networks: networks:
monacousa-network: monacousa-network:
driver: bridge driver: bridge
# ============================================
# Volumes
# ============================================
volumes: volumes:
db-data: portal-data:
driver: local driver: local
storage-data: portal-logs:
driver: local driver: local

114
docker-entrypoint-debug.sh Normal file
View File

@ -0,0 +1,114 @@
#!/bin/sh
set -e
echo "=== MonacoUSA Portal Debug Startup ==="
echo "Timestamp: $(date)"
echo "Node Version: $(node --version)"
echo "NPM Version: $(npm --version)"
echo "Working Directory: $(pwd)"
echo "User: $(whoami)"
echo "UID: $(id -u)"
echo "GID: $(id -g)"
echo ""
echo "=== Environment Variables ==="
echo "NODE_ENV: $NODE_ENV"
echo "NUXT_HOST: $NUXT_HOST"
echo "NUXT_PORT: $NUXT_PORT"
echo "NITRO_HOST: $NITRO_HOST"
echo "NITRO_PORT: $NITRO_PORT"
# Check if Keycloak variables are set
if [ -n "$NUXT_KEYCLOAK_ISSUER" ]; then
echo "NUXT_KEYCLOAK_ISSUER: $NUXT_KEYCLOAK_ISSUER"
echo "NUXT_KEYCLOAK_CLIENT_ID: $NUXT_KEYCLOAK_CLIENT_ID"
echo "NUXT_KEYCLOAK_CLIENT_SECRET: [SET]"
echo "NUXT_KEYCLOAK_CALLBACK_URL: $NUXT_KEYCLOAK_CALLBACK_URL"
else
echo "⚠️ Keycloak variables not set"
fi
# Check if NocoDB variables are set
if [ -n "$NUXT_NOCODB_URL" ]; then
echo "NUXT_NOCODB_URL: $NUXT_NOCODB_URL"
echo "NUXT_NOCODB_TOKEN: [SET]"
echo "NUXT_NOCODB_BASE_ID: $NUXT_NOCODB_BASE_ID"
else
echo "⚠️ NocoDB variables not set"
fi
# Check session secrets
if [ -n "$NUXT_SESSION_SECRET" ]; then
echo "NUXT_SESSION_SECRET: [SET - ${#NUXT_SESSION_SECRET} chars]"
else
echo "❌ NUXT_SESSION_SECRET: NOT SET"
fi
if [ -n "$NUXT_ENCRYPTION_KEY" ]; then
echo "NUXT_ENCRYPTION_KEY: [SET - ${#NUXT_ENCRYPTION_KEY} chars]"
else
echo "❌ NUXT_ENCRYPTION_KEY: NOT SET"
fi
echo ""
echo "=== File System Check ==="
echo "Contents of /app:"
ls -la /app/
if [ -d "/app/.output" ]; then
echo ""
echo "Contents of /app/.output:"
ls -la /app/.output/
if [ -f "/app/.output/server/index.mjs" ]; then
echo "✅ Server file exists: /app/.output/server/index.mjs"
echo "Server file size: $(stat -c%s /app/.output/server/index.mjs) bytes"
else
echo "❌ Server file missing: /app/.output/server/index.mjs"
fi
else
echo "❌ .output directory missing!"
fi
echo ""
echo "=== Network Check ==="
echo "Checking if port $NUXT_PORT is available..."
if netstat -tuln | grep ":$NUXT_PORT "; then
echo "⚠️ Port $NUXT_PORT is already in use"
else
echo "✅ Port $NUXT_PORT is available"
fi
echo ""
echo "=== Service Connectivity Check ==="
# Test Keycloak connectivity
if [ -n "$NUXT_KEYCLOAK_ISSUER" ]; then
echo "Testing Keycloak connectivity..."
if wget -q --spider --timeout=10 "$NUXT_KEYCLOAK_ISSUER" 2>/dev/null; then
echo "✅ Keycloak is reachable: $NUXT_KEYCLOAK_ISSUER"
else
echo "❌ Keycloak is NOT reachable: $NUXT_KEYCLOAK_ISSUER"
fi
fi
# Test NocoDB connectivity
if [ -n "$NUXT_NOCODB_URL" ]; then
echo "Testing NocoDB connectivity..."
if wget -q --spider --timeout=10 "$NUXT_NOCODB_URL" 2>/dev/null; then
echo "✅ NocoDB is reachable: $NUXT_NOCODB_URL"
else
echo "❌ NocoDB is NOT reachable: $NUXT_NOCODB_URL"
fi
fi
echo ""
echo "=== Starting Application ==="
echo "Command: node .output/server/index.mjs"
echo "Starting at: $(date)"
# Set Node.js to output logs immediately
export NODE_OPTIONS="--max-old-space-size=8192 --trace-warnings"
# Start the application with verbose logging
exec node .output/server/index.mjs

34
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/sh
set -e
# Docker entrypoint script for MonacoUSA Portal
echo "Starting MonacoUSA Portal..."
# Check if .env file exists in volume
if [ -f "/app/data/.env" ]; then
echo "Using .env file from volume..."
cp /app/data/.env /app/.env
else
echo "Warning: No .env file found in volume. Using environment variables only."
fi
# Validate required environment variables
if [ -z "$NUXT_KEYCLOAK_ISSUER" ] && [ ! -f "/app/.env" ]; then
echo "Error: NUXT_KEYCLOAK_ISSUER is required"
exit 1
fi
if [ -z "$NUXT_NOCODB_URL" ] && [ ! -f "/app/.env" ]; then
echo "Error: NUXT_NOCODB_URL is required"
exit 1
fi
# Wait for dependencies to be ready (optional)
if [ -n "$WAIT_FOR_SERVICES" ]; then
echo "Waiting for services to be ready..."
sleep 10
fi
# Start the application
echo "Starting Nuxt application on port 3000..."
exec node .output/server/index.mjs

18768
docs/keycloak_api.json Normal file

File diff suppressed because it is too large Load Diff

1975
docs/minio_example_guide.md Normal file

File diff suppressed because it is too large Load Diff

214
error.vue Normal file
View File

@ -0,0 +1,214 @@
<template>
<div class="error-page">
<v-app>
<v-main>
<v-container class="fill-height">
<v-row justify="center" align="center" class="fill-height">
<v-col cols="12" md="8" lg="6" class="text-center">
<!-- Logo -->
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="120"
height="120"
class="mx-auto mb-6"
/>
<!-- Error Code -->
<h1 class="text-h1 font-weight-bold mb-4" style="color: #a31515;">
{{ error.statusCode }}
</h1>
<!-- Error Title -->
<h2 class="text-h3 mb-4 text-grey-darken-2">
{{ getErrorTitle(error.statusCode) }}
</h2>
<!-- Error Message -->
<p class="text-h6 mb-6 text-medium-emphasis" style="max-width: 600px; margin: 0 auto;">
{{ getErrorMessage(error.statusCode) }}
</p>
<!-- Additional Info for 403 -->
<v-alert
v-if="error.statusCode === 403"
type="warning"
variant="tonal"
class="mb-6 text-left"
style="max-width: 500px; margin: 0 auto;"
>
<v-alert-title>Access Restricted</v-alert-title>
<p class="mb-2">This resource requires specific permissions:</p>
<ul class="ml-4">
<li v-if="error.statusMessage?.includes('Board')">Board membership required</li>
<li v-if="error.statusMessage?.includes('Admin')">Administrator privileges required</li>
<li v-if="!error.statusMessage?.includes('Board') && !error.statusMessage?.includes('Admin')">
Higher access level required
</li>
</ul>
</v-alert>
<!-- Action Buttons -->
<div class="d-flex flex-column flex-sm-row justify-center gap-4 mb-6">
<v-btn
color="primary"
size="large"
style="background-color: #a31515;"
@click="goHome"
>
<v-icon start>mdi-home</v-icon>
Go to Dashboard
</v-btn>
<v-btn
variant="outlined"
size="large"
style="border-color: #a31515; color: #a31515;"
@click="goBack"
>
<v-icon start>mdi-arrow-left</v-icon>
Go Back
</v-btn>
</div>
<!-- Contact Support for 403 -->
<div v-if="error.statusCode === 403" class="mt-8">
<v-divider class="mb-4" />
<p class="text-body-2 text-medium-emphasis mb-3">
Need access to this resource?
</p>
<v-btn
variant="text"
color="primary"
@click="contactSupport"
>
<v-icon start>mdi-email</v-icon>
Contact Administrator
</v-btn>
</div>
<!-- Debug Info (development only) -->
<div v-if="isDevelopment" class="mt-8 pa-4 bg-grey-lighten-4 rounded">
<p class="text-caption text-grey-darken-1 mb-2">Debug Information:</p>
<p class="text-caption font-mono">{{ error.statusMessage }}</p>
<p class="text-caption font-mono">{{ error.url }}</p>
</div>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</div>
</template>
<script setup lang="ts">
interface ErrorProps {
error: {
statusCode: number;
statusMessage: string;
url?: string;
};
}
const props = defineProps<ErrorProps>();
// Check if we're in development mode
const isDevelopment = process.dev;
// Error title mapping
const getErrorTitle = (code: number): string => {
switch (code) {
case 403: return 'Access Denied';
case 404: return 'Page Not Found';
case 500: return 'Server Error';
case 401: return 'Unauthorized';
default: return 'Something Went Wrong';
}
};
// Error message mapping
const getErrorMessage = (code: number): string => {
switch (code) {
case 403:
return 'You do not have the required permissions to access this resource. Please contact your administrator if you believe this is an error.';
case 404:
return 'The page you are looking for could not be found. It may have been moved, deleted, or you may have entered the wrong URL.';
case 500:
return 'An internal server error occurred. Our team has been notified and is working to resolve the issue. Please try again later.';
case 401:
return 'You need to be logged in to access this resource. Please sign in and try again.';
default:
return 'An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.';
}
};
// Navigation methods
const goHome = () => {
navigateTo('/dashboard');
};
const goBack = () => {
if (window.history.length > 1) {
window.history.back();
} else {
navigateTo('/dashboard');
}
};
const contactSupport = () => {
// TODO: Implement support contact (email, help desk, etc.)
window.location.href = 'mailto:support@monacousa.org?subject=Access Request&body=I need access to a restricted resource.';
};
// Set page title
useHead({
title: `Error ${props.error.statusCode} - MonacoUSA Portal`,
meta: [
{ name: 'robots', content: 'noindex' }
]
});
</script>
<style scoped>
.error-page {
min-height: 100vh;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.v-main {
background: transparent !important;
}
.font-mono {
font-family: 'Courier New', monospace;
}
.v-btn {
text-transform: none !important;
}
.v-alert {
text-align: left;
}
.v-alert ul {
margin-bottom: 0;
}
.v-alert li {
margin-bottom: 4px;
}
@media (max-width: 600px) {
.text-h1 {
font-size: 4rem !important;
}
.text-h3 {
font-size: 1.75rem !important;
}
.text-h6 {
font-size: 1.1rem !important;
}
}
</style>

18768
keycloak-rest-api.json Normal file

File diff suppressed because it is too large Load Diff

667
layouts/admin.vue Normal file
View File

@ -0,0 +1,667 @@
<template>
<v-app>
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Enhanced Logo Section -->
<v-list-item class="pa-4 text-center enhanced-glass-logo">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2 shimmer-animation"
/>
<div class="text-h6 font-weight-bold text-gradient">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="glass-badge mt-1"
>
ADMINISTRATOR
</v-chip>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto shimmer-animation"
/>
</template>
</v-list-item>
<v-divider class="glass-divider mx-3" />
<!-- Enhanced Navigation Menu -->
<v-list nav density="comfortable" class="enhanced-glass-nav">
<!-- Admin Overview -->
<v-tooltip
:text="miniVariant ? 'Admin Dashboard' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/admin/dashboard"
prepend-icon="mdi-view-dashboard"
:title="!miniVariant ? 'Admin Dashboard' : undefined"
value="dashboard"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- User Management -->
<v-list-group value="users" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-cog"
title="User Management"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/users"
title="All Users"
value="users-list"
class="glass-nav-item-sub"
/>
<v-list-item
@click="openKeycloak"
title="Keycloak Admin"
value="keycloak"
class="glass-nav-item-sub"
>
<template v-slot:append>
<v-icon size="small" class="monaco-red-text">mdi-open-in-new</v-icon>
</template>
</v-list-item>
</v-list-group>
<!-- Member Management -->
<v-list-group value="members" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-group"
title="Member Management"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/members"
title="All Members"
value="members-list"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Financial Management -->
<v-list-group value="financial" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-currency-usd"
title="Financial"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/payments"
title="Payment Management"
value="payments"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- System Configuration -->
<v-list-group value="system" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-cog"
title="System"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/admin/settings"
title="General Settings"
value="settings"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Events Management -->
<v-tooltip
:text="miniVariant ? 'Events Management' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/admin/events"
prepend-icon="mdi-calendar"
:title="!miniVariant ? 'Events Management' : undefined"
value="events"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-divider class="my-2 glass-divider" />
<!-- Portal Access -->
<v-list-subheader v-if="!miniVariant" class="monaco-subheader">Portal Access</v-list-subheader>
<v-tooltip
:text="miniVariant ? 'Board Portal' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/dashboard"
prepend-icon="mdi-shield-account"
:title="!miniVariant ? 'Board Portal' : undefined"
value="board-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-tooltip
:text="miniVariant ? 'Member Portal' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/member/dashboard"
prepend-icon="mdi-account"
:title="!miniVariant ? 'Member Portal' : undefined"
value="member-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
</v-list>
<!-- Enhanced Profile Card -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<ProfileAvatar
:member-id="memberData?.member_id || memberData?.Id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:member-name="memberData?.FullName || user?.name"
:size="miniVariant ? '32' : 'small'"
:class="miniVariant ? '' : 'mr-3'"
/>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ user?.name || 'Administrator' }}</div>
<div class="text-caption text-medium-emphasis">{{ user?.email?.split('@')[0] || 'admin' }}</div>
<v-chip size="x-small" class="mt-1 glass-badge">Admin</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="text"
class="profile-menu-btn"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="logout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar admin-bar">
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn mr-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<v-toolbar-title class="font-weight-bold d-flex align-center text-white">
Admin Portal
<v-chip
size="x-small"
class="ml-2 glass-chip"
>
FULL ACCESS
</v-chip>
</v-toolbar-title>
<v-spacer />
<!-- System Status Indicator with Glass Effect -->
<v-chip
:color="systemStatus === 'healthy' ? 'success' : 'warning'"
variant="flat"
size="small"
class="mr-2 glass-chip"
>
<v-icon start size="small">
{{ systemStatus === 'healthy' ? 'mdi-check-circle' : 'mdi-alert' }}
</v-icon>
System {{ systemStatus }}
</v-chip>
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Administrator' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
ADMINISTRATOR
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/board/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-shield-account</v-icon>
</template>
<v-list-item-title>Board Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/member/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account-switch</v-icon>
</template>
<v-list-item-title>Member Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/admin/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>System Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main class="glass-main">
<v-container fluid class="pa-6">
<!-- System Alerts Banner with Glass Effect -->
<v-alert
v-if="systemAlerts.length > 0"
type="warning"
variant="tonal"
closable
class="mb-4 glass-alert"
>
<v-alert-title>System Alerts</v-alert-title>
<ul class="mt-2">
<li v-for="alert in systemAlerts" :key="alert.id">
{{ alert.message }}
</li>
</ul>
</v-alert>
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const alerts = ref(0);
const systemStatus = ref<'healthy' | 'warning' | 'error'>('healthy');
const systemAlerts = ref<Array<{ id: number; message: string }>>([]);
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Load admin-specific data
onMounted(async () => {
try {
// Check system health
const healthCheck = await $fetch('/api/admin/system/health');
systemStatus.value = healthCheck?.data?.status || 'healthy';
// Get critical alerts
const alertsRes = await $fetch('/api/admin/alerts');
alerts.value = alertsRes?.data?.count || 0;
systemAlerts.value = alertsRes?.data?.alerts || [];
} catch (error) {
console.error('Error fetching admin data:', error);
systemStatus.value = 'warning';
}
});
const openKeycloak = () => {
window.open('https://auth.monacousa.org/admin', '_blank');
};
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Glass Drawer Styles
.glass-drawer {
@include glass-effect(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
}
.glass-logo-section {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(255, 255, 255, 0.8) 100%);
border-radius: 16px;
margin-bottom: 8px;
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
.monaco-muted-text {
color: #71717a;
}
// Glass Navigation Items
.glass-nav-list {
background: transparent !important;
}
.glass-nav-item {
border-radius: 12px !important;
margin: 4px 12px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
transform: translateX(2px);
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 70%;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-radius: 0 2px 2px 0;
}
.v-icon {
color: #dc2626 !important;
}
}
}
.glass-nav-item-sub {
padding-left: 52px !important;
border-radius: 8px !important;
margin: 2px 12px 2px 24px !important;
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.03) !important;
}
&.v-list-item--active {
background: rgba(220, 38, 38, 0.08) !important;
color: #dc2626 !important;
}
}
// Monaco Subheader
.monaco-subheader {
color: #dc2626 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Admin App Bar with Gradient
.admin-bar {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.95) 0%,
rgba(153, 27, 27, 0.95) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
// Glass Chips
.glass-chip {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white !important;
}
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Input
.glass-input {
:deep(.v-field) {
background: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(220, 38, 38, 0.2);
}
}
// Glass Alert
.glass-alert {
@include glass-effect(0.8, 15px);
border: 1px solid rgba(245, 158, 11, 0.2) !important;
}
// Glass Main Background
.glass-main {
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Responsive adjustments
@media (max-width: 1024px) {
.glass-nav-item {
margin: 2px 8px !important;
}
.glass-nav-item-sub {
margin: 2px 8px 2px 16px !important;
}
}
</style>

743
layouts/board.vue Normal file
View File

@ -0,0 +1,743 @@
<template>
<v-app style="background-color: #fafafa;">
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Enhanced Logo Section -->
<v-list-item class="pa-4 text-center enhanced-glass-logo">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2 shimmer-animation"
/>
<div class="text-h6 font-weight-bold text-gradient">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="glass-badge mt-1"
>
BOARD MEMBER
</v-chip>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto shimmer-animation"
/>
</template>
</v-list-item>
<v-divider class="glass-divider mx-3" />
<!-- Enhanced Navigation Menu -->
<v-list nav density="comfortable" class="enhanced-glass-nav">
<!-- Board Overview -->
<v-tooltip
:text="miniVariant ? 'Board Dashboard' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/dashboard"
prepend-icon="mdi-view-dashboard"
:title="!miniVariant ? 'Board Dashboard' : undefined"
value="dashboard"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Member Management -->
<v-list-group value="members" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-account-group"
title="Members"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/board/members"
title="Member Directory"
value="member-list"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/members/dues"
title="Dues Management"
value="dues"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/members/applications"
title="Applications"
value="applications"
class="glass-nav-item-sub"
>
<template v-slot:append>
<v-badge
:content="pendingApplications"
:value="pendingApplications > 0"
color="error"
class="glass-badge"
/>
</template>
</v-list-item>
</v-list-group>
<!-- Member Management (Collapsed) -->
<v-tooltip
v-if="miniVariant"
text="Members"
location="end"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/members"
prepend-icon="mdi-account-group"
value="members-collapsed"
class="glass-nav-item animated-nav-item"
>
<template v-if="pendingApplications > 0" v-slot:append>
<v-badge
:content="pendingApplications"
color="error"
class="glass-badge"
/>
</template>
</v-list-item>
</template>
</v-tooltip>
<!-- Events & Meetings -->
<v-list-group value="events" v-if="!miniVariant">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
prepend-icon="mdi-calendar"
title="Events & Meetings"
class="glass-nav-item animated-nav-item"
/>
</template>
<v-list-item
to="/board/events"
title="All Events"
value="events"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/meetings"
title="Board Meetings"
value="meetings"
class="glass-nav-item-sub"
/>
<v-list-item
to="/board/meetings/minutes"
title="Meeting Minutes"
value="minutes"
class="glass-nav-item-sub"
/>
</v-list-group>
<!-- Events & Meetings (Collapsed) -->
<v-tooltip
v-if="miniVariant"
text="Events & Meetings"
location="end"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/events"
prepend-icon="mdi-calendar"
value="events-collapsed"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Reports & Analytics -->
<v-tooltip
:text="miniVariant ? 'Reports & Analytics' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/reports"
prepend-icon="mdi-chart-box"
:title="!miniVariant ? 'Reports & Analytics' : undefined"
value="reports"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Governance -->
<v-tooltip
:text="miniVariant ? 'Governance' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/governance"
prepend-icon="mdi-gavel"
:title="!miniVariant ? 'Governance' : undefined"
value="governance"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<!-- Communications -->
<v-tooltip
:text="miniVariant ? 'Communications' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/board/communications"
prepend-icon="mdi-email-newsletter"
:title="!miniVariant ? 'Communications' : undefined"
value="communications"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
<v-divider class="my-2 glass-divider" />
<!-- Member Section Access -->
<v-list-subheader v-if="!miniVariant" class="monaco-subheader">Member Portal</v-list-subheader>
<v-tooltip
:text="miniVariant ? 'Member View' : ''"
location="end"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
to="/member/dashboard"
prepend-icon="mdi-account"
:title="!miniVariant ? 'Member View' : undefined"
value="member-view"
class="glass-nav-item animated-nav-item"
/>
</template>
</v-tooltip>
</v-list>
<!-- Enhanced Profile Card -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible" style="background: linear-gradient(135deg, rgba(220, 38, 38, 0.08), rgba(255, 255, 255, 0.95)); border: 1px solid rgba(220, 38, 38, 0.2);">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<div style="position: relative;">
<ProfileAvatar
:member-id="memberData?.member_id || memberData?.Id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:member-name="memberData?.FullName || user?.name"
:size="miniVariant ? '32' : '48'"
:class="miniVariant ? '' : 'mr-3'"
show-border
style="border: 2px solid #dc2626; box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);"
/>
<v-icon
v-if="!miniVariant"
size="16"
color="green"
style="position: absolute; bottom: 0; right: 12px; background: white; border-radius: 50%; padding: 2px;"
>
mdi-check-circle
</v-icon>
</div>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ user?.name || 'Board Member' }}</div>
<div class="text-caption text-medium-emphasis">{{ user?.email?.split('@')[0] || 'board' }}</div>
<v-chip size="x-small" class="mt-1" style="background: linear-gradient(135deg, #dc2626, #b91c1c); color: white;">
<v-icon start size="12">mdi-shield-check</v-icon>
Board
</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="tonal"
color="primary"
class="profile-menu-btn"
style="background: rgba(220, 38, 38, 0.1);"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item @click="() => {}" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="logout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar board-bar">
<v-toolbar-title class="font-weight-bold text-white">
Board Portal
</v-toolbar-title>
<v-spacer />
<!-- Quick Actions with Glass Effects -->
<v-btn
icon
class="glass-icon-btn"
@click="toggleSearch"
>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<!-- Move hamburger menu to the right side -->
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn ml-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Board Member' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
BOARD MEMBER
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/board/profile" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>Board Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/dashboard" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account-switch</v-icon>
</template>
<v-list-item-title>Member Portal</v-list-item-title>
</v-list-item>
<v-list-item to="/board/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<!-- Search Overlay with Glass Effect -->
<v-dialog v-model="searchOpen" max-width="600" persistent>
<v-card class="glass-card">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2 monaco-red-text">mdi-magnify</v-icon>
Search Members
<v-spacer />
<v-btn icon @click="searchOpen = false" class="glass-icon-btn-dark">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-text-field
v-model="searchQuery"
label="Search by name, email, or member ID"
prepend-inner-icon="mdi-magnify"
variant="outlined"
autofocus
@keyup.enter="performSearch"
class="glass-input"
/>
</v-card-text>
</v-card>
</v-dialog>
<v-main class="glass-main">
<v-container fluid class="pa-6">
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import ProfileAvatar from '~/components/ProfileAvatar.vue';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const notifications = ref(0);
const pendingApplications = ref(0);
const searchOpen = ref(false);
const searchQuery = ref('');
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Load board-specific notifications
onMounted(async () => {
try {
const [notificationsRes, applicationsRes] = await Promise.all([
$fetch('/api/board/notifications/count'),
$fetch('/api/board/applications/pending/count')
]);
notifications.value = notificationsRes?.data?.count || 0;
pendingApplications.value = applicationsRes?.data?.count || 0;
} catch (error) {
console.error('Error fetching board data:', error);
}
});
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const toggleSearch = () => {
searchOpen.value = true;
};
const performSearch = () => {
if (searchQuery.value) {
navigateTo(`/board/members?search=${encodeURIComponent(searchQuery.value)}`);
searchOpen.value = false;
searchQuery.value = '';
}
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Glass Drawer Styles
.glass-drawer {
@include glass-effect(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
}
.glass-logo-section {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.05) 0%,
rgba(255, 255, 255, 0.8) 100%);
border-radius: 16px;
margin-bottom: 8px;
}
.float-animation {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
// Glass Navigation Items
.glass-nav-list {
background: transparent !important;
}
.glass-nav-item {
border-radius: 12px !important;
margin: 4px 12px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
transform: translateX(2px);
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 70%;
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-radius: 0 2px 2px 0;
}
.v-icon {
color: #dc2626 !important;
}
}
}
.glass-nav-item-sub {
padding-left: 52px !important;
border-radius: 8px !important;
margin: 2px 12px 2px 24px !important;
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.03) !important;
}
&.v-list-item--active {
background: rgba(220, 38, 38, 0.08) !important;
color: #dc2626 !important;
}
}
// Monaco Subheader
.monaco-subheader {
color: #dc2626 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Board App Bar with Gradient
.board-bar {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.9) 0%,
rgba(124, 45, 18, 0.9) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.glass-icon-btn-dark {
background: rgba(0, 0, 0, 0.05) !important;
backdrop-filter: blur(10px);
color: #71717a !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(0, 0, 0, 0.1) !important;
transform: translateY(-1px);
}
}
// Glass Badge
.glass-badge {
:deep(.v-badge__badge) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
}
// Monaco Chip
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Input
.glass-input {
:deep(.v-field) {
background: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(220, 38, 38, 0.2);
}
}
// Glass Main Background
.glass-main {
background-color: #fafafa; // Solid fallback for Edge and other browsers
background-image: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Responsive adjustments
@media (max-width: 1024px) {
.glass-nav-item {
margin: 2px 8px !important;
}
.glass-nav-item-sub {
margin: 2px 8px 2px 16px !important;
}
}
</style>

280
layouts/dashboard.vue Normal file
View File

@ -0,0 +1,280 @@
<template>
<v-app>
<v-navigation-drawer v-model="drawer" app width="280">
<!-- Logo Section -->
<v-list-item class="pa-4 text-center">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto mb-2"
/>
<div class="text-h6 font-weight-bold" style="color: #a31515;">
MonacoUSA Portal
</div>
</v-list-item>
<v-divider />
<!-- Navigation Menu -->
<v-list nav>
<!-- Always visible items -->
<v-list-item
to="/dashboard"
prepend-icon="mdi-view-dashboard"
title="Dashboard"
value="dashboard"
/>
<v-list-item
to="/dashboard/events"
prepend-icon="mdi-calendar"
title="Events"
value="events"
/>
<v-list-item
to="/dashboard/user"
prepend-icon="mdi-account"
title="My Profile"
value="profile"
/>
<!-- Board-only items -->
<template v-if="isBoard || isAdmin">
<v-divider class="my-2" />
<v-list-subheader>Board Tools</v-list-subheader>
<v-list-item
to="/dashboard/member-list"
prepend-icon="mdi-account-group"
title="Member List"
value="members"
/>
<v-list-item
to="/dashboard/board"
prepend-icon="mdi-shield-account"
title="Board Dashboard"
value="board-dashboard"
/>
</template>
<!-- Admin-only items -->
<template v-if="isAdmin">
<v-divider class="my-2" />
<v-list-subheader>Administration</v-list-subheader>
<v-list-item
@click="openUserManagement"
prepend-icon="mdi-account-cog"
title="Manage Users"
value="admin-users"
/>
<v-list-item
to="/dashboard/admin"
prepend-icon="mdi-cog"
title="Admin Panel"
value="admin-panel"
/>
</template>
</v-list>
<!-- Footer -->
<template v-slot:append>
<div class="pa-4 text-center">
<v-chip
:color="getTierColor(userTier)"
size="small"
variant="elevated"
>
<v-icon start :icon="getTierIcon(userTier)" />
{{ userTier.toUpperCase() }}
</v-chip>
</div>
</template>
</v-navigation-drawer>
<v-app-bar app color="primary" elevation="2">
<!-- MonacoUSA Logo -->
<MonacoUSALogo
size="small"
variant="white"
class="mr-2"
/>
<v-toolbar-title class="text-white font-weight-bold">
MonacoUSA Portal
</v-toolbar-title>
<v-spacer />
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" color="white">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="200">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'User' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-subtitle>
<v-chip
:color="getTierColor(userTier)"
size="x-small"
variant="flat"
>
{{ userTier.toUpperCase() }} TIER
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-divider />
<v-list-item @click="navigateToProfile">
<v-list-item-title>
<v-icon start>mdi-account</v-icon>
Profile
</v-list-item-title>
</v-list-item>
<v-list-item @click="navigateToSettings">
<v-list-item-title>
<v-icon start>mdi-cog</v-icon>
Settings
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="handleLogout" class="text-error">
<v-list-item-title>
<v-icon start>mdi-logout</v-icon>
Logout
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main>
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<v-container fluid>
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
const { user, userTier, isBoard, isAdmin, logout } = useAuth();
const drawer = ref(true);
// Fetch complete member data for profile avatar
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Helper functions
const getTierColor = (tier: string) => {
switch (tier) {
case 'admin': return 'error';
case 'board': return 'primary';
case 'user': return 'info';
default: return 'grey';
}
};
const getTierIcon = (tier: string) => {
switch (tier) {
case 'admin': return 'mdi-shield-crown';
case 'board': return 'mdi-shield-account';
case 'user': return 'mdi-account';
default: return 'mdi-account';
}
};
// Navigation methods
const openUserManagement = () => {
window.open('https://auth.monacousa.org', '_blank');
};
const navigateToProfile = () => {
navigateTo('/dashboard/profile');
};
const navigateToSettings = () => {
navigateTo('/dashboard/admin');
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024; // Show drawer on desktop by default
}, { immediate: true });
</script>
<style scoped>
.v-navigation-drawer {
border-right: 1px solid rgba(0, 0, 0, 0.12);
}
.v-list-item {
border-radius: 8px;
margin: 2px 8px;
}
.v-list-item--active {
background-color: rgba(163, 21, 21, 0.1) !important;
color: #a31515 !important;
}
.v-list-item--active .v-icon {
color: #a31515 !important;
}
.v-list-subheader {
color: #a31515 !important;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.v-app-bar {
background: linear-gradient(135deg, #a31515 0%, #8b1212 100%) !important;
}
.v-main {
background-color: #f8f9fa;
}
</style>

640
layouts/member.vue Normal file
View File

@ -0,0 +1,640 @@
<template>
<v-app style="background-color: #fafafa;">
<v-navigation-drawer
v-model="drawer"
:rail="miniVariant"
:expand-on-hover="false"
permanent
width="280"
rail-width="100"
class="enhanced-glass-drawer"
>
<!-- Logo Section with Enhanced Glass Effect -->
<v-list-item class="logo-section">
<template v-if="!miniVariant">
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="80"
height="80"
class="mx-auto logo-image mb-2"
/>
<div class="logo-text">
<div class="text-h6 font-weight-bold monaco-red-text">
MonacoUSA Portal
</div>
<v-chip
size="x-small"
class="monaco-chip-gradient mt-1"
>
MEMBER
</v-chip>
</div>
</template>
<template v-else>
<v-img
src="/MONACOUSA-Flags_376x376.png"
width="40"
height="40"
class="mx-auto logo-image"
/>
</template>
</v-list-item>
<v-divider class="glass-divider" />
<!-- Navigation Menu with Enhanced Effects -->
<v-list nav class="enhanced-nav-list">
<template v-for="item in navigationItems" :key="item.value">
<v-tooltip
:text="item.title"
location="right"
:disabled="!miniVariant"
>
<template v-slot:activator="{ props }">
<v-list-item
:to="item.to"
:prepend-icon="item.icon"
:title="!miniVariant ? item.title : undefined"
:value="item.value"
class="nav-item-enhanced"
v-bind="props"
>
<template v-if="item.badge" v-slot:append>
<v-badge
:content="item.badge"
color="error"
inline
:dot="miniVariant"
/>
</template>
</v-list-item>
</template>
</v-tooltip>
</template>
</v-list>
<!-- Enhanced Profile Footer -->
<template v-slot:append>
<div class="pa-2">
<v-card class="glass-profile-card overflow-visible">
<div class="d-flex align-center" :class="miniVariant ? 'flex-column py-3 px-2' : 'pa-3'">
<!-- Avatar Section -->
<div class="position-relative">
<ProfileAvatar
v-if="memberData"
:member-id="memberData?.member_id"
:first-name="memberData?.first_name || user?.firstName"
:last-name="memberData?.last_name || user?.lastName"
:size="miniVariant ? '32' : 'small'"
:class="miniVariant ? '' : 'mr-3'"
:show-badge="false"
/>
<div v-if="!miniVariant" class="online-indicator" />
</div>
<!-- Info Section (Hidden in mini mode) -->
<div v-if="!miniVariant" class="flex-grow-1">
<div class="text-subtitle-2 font-weight-bold">{{ fullName }}</div>
<div class="text-caption text-medium-emphasis">{{ email?.split('@')[0] || 'member' }}</div>
<v-chip size="x-small" class="mt-1 glass-badge">Member</v-chip>
</div>
<!-- Action Buttons -->
<div :class="miniVariant ? 'mt-2' : 'ml-auto'">
<v-menu location="top" offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
:size="miniVariant ? 'small' : 'small'"
variant="text"
class="profile-menu-btn"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list density="compact" class="glass-menu" min-width="200">
<v-list-item to="/member/profile" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="primary">mdi-account-circle</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/settings" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="info">mdi-cog-outline</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-1 glass-divider" />
<v-list-item @click="handleLogout" class="hover-lift">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-logout-variant</v-icon>
</template>
<v-list-item-title class="text-error">Sign Out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</v-card>
</div>
</template>
</v-navigation-drawer>
<v-app-bar elevation="0" flat class="glass-app-bar member-bar">
<v-btn
icon
@click="toggleDrawer"
class="glass-icon-btn mr-2"
>
<v-icon>{{ miniVariant ? 'mdi-menu' : 'mdi-menu-open' }}</v-icon>
</v-btn>
<v-toolbar-title class="font-weight-bold text-white">
Member Portal
</v-toolbar-title>
<v-spacer />
<!-- User Menu -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="glass-icon-btn">
<ProfileAvatar
:member-id="memberData?.member_id"
:member-name="user?.name"
:first-name="user?.firstName || memberData?.first_name"
:last-name="user?.lastName || memberData?.last_name"
size="small"
:lazy="false"
show-border
/>
</v-btn>
</template>
<v-list min-width="250" class="glass-dropdown">
<v-list-item>
<v-list-item-title class="font-weight-bold">
{{ user?.name || 'Member' }}
</v-list-item-title>
<v-list-item-subtitle>
{{ user?.email }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-chip
size="x-small"
class="monaco-chip-gradient"
>
MEMBER
</v-chip>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item to="/member/profile" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>My Profile</v-list-item-title>
</v-list-item>
<v-list-item to="/member/settings" class="glass-dropdown-item">
<template v-slot:prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider class="my-2 glass-divider" />
<v-list-item @click="handleLogout" class="glass-dropdown-item text-error">
<template v-slot:prepend>
<v-icon color="error">mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<v-main class="glass-main">
<!-- Dues Payment Banner with Glass Effect -->
<DuesPaymentBanner />
<v-container fluid class="pa-6">
<slot />
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
const { user, logout } = useAuth();
const drawer = ref(true);
const miniVariant = ref(false);
const notifications = ref(0);
// Navigation items configuration
const navigationItems = ref([
{
to: '/member/dashboard',
icon: 'mdi-view-dashboard',
title: 'Dashboard',
value: 'dashboard'
},
{
to: '/member/profile',
icon: 'mdi-account',
title: 'My Profile',
value: 'profile'
},
{
to: '/member/events',
icon: 'mdi-calendar',
title: 'Events',
value: 'events',
badge: '3' // Example badge
},
{
to: '/member/payments',
icon: 'mdi-credit-card',
title: 'Payments & Dues',
value: 'payments'
},
{
to: '/member/resources',
icon: 'mdi-book-open-variant',
title: 'Resources',
value: 'resources'
}
]);
// Fetch member data
const { data: sessionData } = await useFetch<{ success: boolean; member: Member | null }>('/api/auth/session', {
server: false
});
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Computed properties for profile
const fullName = computed(() => {
if (memberData.value) {
return `${memberData.value.first_name} ${memberData.value.last_name}`;
}
return user.value?.name || 'Member';
});
const email = computed(() => memberData.value?.email || user.value?.email || '');
// Check for notifications
onMounted(async () => {
// Check for upcoming events, dues reminders, etc.
try {
const { data } = await $fetch('/api/member/notifications/count');
notifications.value = data?.count || 0;
} catch (error) {
console.error('Error fetching notifications:', error);
}
});
const toggleDrawer = () => {
miniVariant.value = !miniVariant.value;
};
const handleLogout = async () => {
await logout();
};
// Responsive drawer behavior
const { width } = useDisplay();
watch(width, (newWidth) => {
drawer.value = newWidth >= 1024;
}, { immediate: true });
</script>
<style scoped lang="scss">
@import '~/assets/scss/main.scss';
// Enhanced Glass Drawer Styles
.enhanced-glass-drawer {
@include enhanced-glass(0.95, 30px);
border-right: 1px solid rgba(255, 255, 255, 0.2) !important;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logo-section {
position: relative;
padding: 1.5rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(248, 248, 248, 0.9) 100%);
border-radius: 16px;
margin: 0.5rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
&--collapsed {
padding: 0.75rem;
.logo-image {
margin: 0 auto;
}
}
.logo-image {
transition: all 0.3s ease;
}
.logo-text {
text-align: center;
}
.collapse-btn {
position: absolute;
right: -0.5rem;
top: 50%;
transform: translateY(-50%);
background: white;
border: 1px solid rgba(220, 38, 38, 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: rgba(220, 38, 38, 0.05);
}
}
}
// Monaco Text Colors
.monaco-red-text {
color: #dc2626 !important;
}
// Enhanced Navigation Items
.enhanced-nav-list {
background: transparent !important;
padding: 0.5rem;
}
.nav-item-enhanced {
border-radius: 12px !important;
margin: 4px 8px !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative;
overflow: hidden;
@include ripple-effect();
&:hover {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.08) 0%,
rgba(220, 38, 38, 0.04) 100%) !important;
transform: translateX(4px);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(220, 38, 38, 0.05) 50%,
transparent 100%);
animation: shimmer 1s ease-in-out;
}
}
&.v-list-item--active {
background: linear-gradient(135deg,
rgba(220, 38, 38, 0.15) 0%,
rgba(220, 38, 38, 0.08) 100%) !important;
color: #dc2626 !important;
@include sliding-indicator();
.v-icon {
color: #dc2626 !important;
animation: pulse 2s ease-in-out infinite;
}
}
.v-list-item__prepend {
.v-icon {
transition: all 0.3s ease;
}
}
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
// Glass Divider
.glass-divider {
opacity: 0.2;
border-color: rgba(220, 38, 38, 0.2);
}
// Member App Bar with Gradient
.member-bar {
background: linear-gradient(135deg,
rgba(239, 68, 68, 0.9) 0%,
rgba(220, 38, 38, 0.9) 100%) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
// Glass Icon Buttons
.glass-icon-btn {
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
color: white !important;
transition: all 0.3s ease !important;
&:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
// Monaco Chip
.monaco-chip-gradient {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
color: white !important;
border: none;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.25);
}
// Glass Dropdown
.glass-dropdown {
@include glass-effect(0.95, 20px);
border-radius: 12px !important;
overflow: hidden;
}
.glass-dropdown-item {
transition: all 0.2s ease !important;
&:hover {
background: rgba(220, 38, 38, 0.05) !important;
}
}
// Glass Main Background
.glass-main {
background-color: #fafafa; // Solid fallback for Edge and other browsers
background-image: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
background: linear-gradient(180deg,
rgba(250, 250, 250, 0.9) 0%,
rgba(245, 245, 245, 0.9) 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(220, 38, 38, 0.03) 0%, transparent 50%),
radial-gradient(circle at 40% 20%, rgba(220, 38, 38, 0.03) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
}
// Profile Footer Styles
.profile-footer {
padding: 0.5rem;
}
.profile-card-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.7)
);
border-radius: 12px;
margin: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
&:hover {
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.95),
rgba(255, 255, 255, 0.85)
);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
.profile-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
position: relative;
.profile-menu-btn {
position: absolute;
top: -0.5rem;
right: -0.5rem;
opacity: 0.6;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
.online-indicator {
position: absolute;
bottom: -2px;
right: -2px;
width: 10px;
height: 10px;
background: #22c55e;
border: 2px solid white;
border-radius: 50%;
animation: pulse-online 2s ease-in-out infinite;
}
}
.profile-info {
text-align: center;
width: 100%;
.profile-name {
font-size: 0.925rem;
font-weight: 600;
color: rgb(31, 41, 55);
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-email {
font-size: 0.8rem;
color: rgb(107, 114, 128);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes pulse-online {
0%, 100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(34, 197, 94, 0);
}
}
// Fade transition
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// Responsive adjustments
@media (max-width: 1024px) {
.nav-item-enhanced {
margin: 2px 8px !important;
}
}
</style>

17
middleware/admin.ts Normal file
View File

@ -0,0 +1,17 @@
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isAdmin } = useAuth();
// Check if user is authenticated
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Check if user has admin privileges
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Administrator privileges required.'
});
}
});

14
middleware/auth-admin.ts Normal file
View File

@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
if (!isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Administrator privileges required.'
});
}
});

14
middleware/auth-board.ts Normal file
View File

@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board membership required.'
});
}
});

7
middleware/auth-user.ts Normal file
View File

@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
});

18
middleware/auth.ts Normal file
View File

@ -0,0 +1,18 @@
export default defineNuxtRouteMiddleware(async (to) => {
// Skip auth for public pages
if (to.meta.auth === false) {
return;
}
// Use the same auth system as the rest of the app
const { isAuthenticated, checkAuth, user } = useAuth();
// Ensure auth is checked if user isn't loaded
if (!user.value) {
await checkAuth();
}
if (!isAuthenticated.value) {
return navigateTo('/login');
}
});

15
middleware/board.ts Normal file
View File

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Only board members and admins can access board pages
if (!isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Board member privileges required.'
});
}
});

8
middleware/guest.ts Normal file
View File

@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { user } = useAuth();
// If user is already authenticated, redirect to dashboard
if (user.value) {
return navigateTo('/dashboard');
}
});

15
middleware/member.ts Normal file
View File

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const { isAuthenticated, isUser, isBoard, isAdmin } = useAuth();
if (!isAuthenticated.value) {
return navigateTo('/login');
}
// Members, board members, and admins can all access member pages
if (!isUser.value && !isBoard.value && !isAdmin.value) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. Member privileges required.'
});
}
});

48
nginx-portal.conf Normal file
View File

@ -0,0 +1,48 @@
server {
listen 80;
server_name portal.monacousa.org;
location / {
return 301 https://$host$request_uri;
}
location ^~ /.well-known/acme-challenge/ {
alias /var/www/html/.well-known/acme-challenge/;
default_type "text/plain";
allow all;
}
}
server {
listen 443 ssl;
server_name portal.monacousa.org;
ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
access_log /var/log/nginx/portal.monacousa.org.access.log combined;
error_log /var/log/nginx/portal.monacousa.org.error.log warn;
location / {
proxy_pass http://127.0.0.1:6060;
proxy_http_version 1.1;
proxy_set_header Connection "close";
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_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
}
location ^~ /.well-known/acme-challenge/ {
alias /var/www/html/.well-known/acme-challenge/;
default_type "text/plain";
allow all;
}
}

View File

@ -1,244 +0,0 @@
# 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";
}
}

View File

@ -1,161 +0,0 @@
# 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";
}
}

View File

310
nuxt.config.ts Normal file
View File

@ -0,0 +1,310 @@
export default defineNuxtConfig({
ssr: false,
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
// Add startup logging
hooks: {
'ready': () => {
console.log('🚀 MonacoUSA Portal Nuxt is ready!')
console.log('Environment:', process.env.NODE_ENV)
console.log('Port:', process.env.NUXT_PORT || process.env.PORT || 3000)
},
'listen': (server, { host, port }) => {
console.log(`🌐 Server listening on http://${host}:${port}`)
}
},
modules: ["vuetify-nuxt-module", "@vueuse/motion/nuxt"],
css: ["~/assets/scss/main.scss"],
app: {
head: {
titleTemplate: "%s • MonacoUSA Portal",
title: "MonacoUSA Portal",
meta: [
{ property: "og:title", content: "MonacoUSA Portal" },
{ property: "og:image", content: "/MONACOUSA-Flags_376x376.png" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
{ name: "apple-mobile-web-app-title", content: "MonacoUSA Portal" },
{ name: "theme-color", content: "#a31515" },
],
link: [
{ rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32x32.png" },
{ rel: "icon", type: "image/png", sizes: "192x192", href: "/icon-192x192.png" },
{ rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" },
{ rel: "shortcut icon", href: "/favicon-32x32.png" },
],
htmlAttrs: {
lang: "en",
},
},
},
nitro: {
experimental: {
wasm: true
}
},
vite: {
optimizeDeps: {
exclude: ['sharp']
}
},
runtimeConfig: {
// Server-side configuration
keycloak: {
issuer: process.env.NUXT_KEYCLOAK_ISSUER || "",
clientId: process.env.NUXT_KEYCLOAK_CLIENT_ID || "monacousa-portal",
clientSecret: process.env.NUXT_KEYCLOAK_CLIENT_SECRET || "",
callbackUrl: process.env.NUXT_KEYCLOAK_CALLBACK_URL || "https://portal.monacousa.org/auth/callback",
},
keycloakAdmin: {
issuer: process.env.NUXT_KEYCLOAK_ISSUER || "",
clientId: process.env.NUXT_KEYCLOAK_ADMIN_CLIENT_ID || "admin-cli",
clientSecret: process.env.NUXT_KEYCLOAK_ADMIN_CLIENT_SECRET || "",
},
nocodb: {
url: process.env.NUXT_NOCODB_URL || "",
token: process.env.NUXT_NOCODB_TOKEN || "",
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
eventsBaseId: process.env.NUXT_NOCODB_EVENTS_BASE_ID || "",
eventsTableId: process.env.NUXT_NOCODB_EVENTS_TABLE_ID || "",
rsvpTableId: process.env.NUXT_NOCODB_RSVP_TABLE_ID || "",
},
minio: {
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",
port: parseInt(process.env.NUXT_MINIO_PORT || "443"),
useSSL: process.env.NUXT_MINIO_USE_SSL !== "false",
accessKey: process.env.NUXT_MINIO_ACCESS_KEY || "",
secretKey: process.env.NUXT_MINIO_SECRET_KEY || "",
bucketName: process.env.NUXT_MINIO_BUCKET_NAME || "monacousa-portal",
},
sessionSecret: process.env.NUXT_SESSION_SECRET || "",
encryptionKey: process.env.NUXT_ENCRYPTION_KEY || "",
jwtSecret: process.env.NUXT_JWT_SECRET || process.env.NUXT_SESSION_SECRET || "",
public: {
// Client-side configuration
appName: "MonacoUSA Portal",
domain: process.env.NUXT_PUBLIC_DOMAIN || "https://portal.monacousa.org",
keycloakIssuer: process.env.NUXT_KEYCLOAK_ISSUER || "https://auth.monacousa.org/realms/monacousa",
motion: {
directives: {
'pop-bottom': {
initial: {
scale: 0,
opacity: 0,
y: 100
},
visible: {
scale: 1,
opacity: 1,
y: 0,
transition: {
type: 'spring',
stiffness: 250,
damping: 25
}
}
},
'fade-in': {
initial: {
opacity: 0
},
enter: {
opacity: 1,
transition: {
duration: 600
}
}
},
'slide-up': {
initial: {
y: 100,
opacity: 0
},
enter: {
y: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 100,
damping: 20
}
}
},
'glass-fade': {
initial: {
opacity: 0,
scale: 0.95,
filter: 'blur(10px)'
},
enter: {
opacity: 1,
scale: 1,
filter: 'blur(0px)',
transition: {
duration: 500,
type: 'spring',
stiffness: 200
}
}
}
}
}
},
},
vuetify: {
vuetifyOptions: {
theme: {
defaultTheme: "monacousa",
themes: {
monacousa: {
dark: false,
colors: {
// Refined Monaco Red Spectrum
primary: "#dc2626", // Professional primary
'primary-50': "#fef2f2",
'primary-100': "#fee2e2",
'primary-200': "#fecaca",
'primary-300': "#fca5a5",
'primary-400': "#f87171",
'primary-500': "#ef4444",
'primary-600': "#dc2626", // Primary Brand Color
'primary-700': "#b91c1c",
'primary-800': "#991b1b",
'primary-900': "#7f1d1d",
// Improved Neutral Palette
secondary: "#64748b", // Neutral gray for secondary
accent: "#dc2626", // Monaco red as accent
background: "#fafafa", // Light gray background
surface: "#ffffff", // Pure white surfaces
'on-background': "#1f2937", // Darker text on background
'on-surface': "#1f2937", // Darker text on surface
// Semantic Colors - More Professional
error: "#dc2626",
warning: "#f59e0b",
info: "#3b82f6",
success: "#22c55e",
// Custom Properties for Glass Effects
'glass-bg': "rgba(255, 255, 255, 0.85)",
'glass-border': "rgba(255, 255, 255, 0.18)",
'glass-dark': "rgba(17, 24, 39, 0.6)",
},
variables: {
'border-color': '#e5e7eb',
'border-opacity': 0.08,
'high-emphasis-opacity': 0.95,
'medium-emphasis-opacity': 0.70,
'disabled-opacity': 0.45,
'idle-opacity': 0.02,
'hover-opacity': 0.04,
'focus-opacity': 0.08,
'selected-opacity': 0.08,
'activated-opacity': 0.10,
'pressed-opacity': 0.12,
'dragged-opacity': 0.06,
'shadow-glass': '0 8px 32px rgba(31, 41, 55, 0.08)',
'shadow-monaco': '0 10px 40px rgba(185, 28, 28, 0.1)',
'shadow-elevated': '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
},
},
monacousa_dark: {
dark: true,
colors: {
// Dark theme aligned with design system
primary: "#ef4444", // Brighter red for dark mode
'primary-600': "#dc2626",
'primary-700': "#b91c1c",
secondary: "#fafafa",
accent: "#3f3f46",
background: "#18181b", // gray-900
surface: "#27272a", // gray-800
'on-background': "#fafafa",
'on-surface': "#f4f4f5",
error: "#f87171",
warning: "#fbbf24",
info: "#38bdf8",
success: "#34d399",
'glass-bg': "rgba(0, 0, 0, 0.7)",
'glass-border': "rgba(255, 255, 255, 0.1)",
},
},
},
variations: {
colors: ['primary', 'secondary', 'accent'],
lighten: 4,
darken: 4,
},
},
defaults: {
VCard: {
elevation: 0,
rounded: 'xl',
class: 'card-modern',
},
VBtn: {
elevation: 0,
rounded: 'lg',
class: 'text-none font-medium',
size: 'default',
density: 'comfortable',
},
VNavigationDrawer: {
elevation: 0,
class: 'sidebar-modern',
},
VAppBar: {
elevation: 0,
flat: true,
class: 'appbar-modern',
density: 'comfortable',
},
VTextField: {
variant: 'outlined',
rounded: 'lg',
density: 'comfortable',
class: 'input-modern',
},
VSelect: {
variant: 'outlined',
rounded: 'lg',
density: 'comfortable',
class: 'select-modern',
},
VDataTable: {
class: 'table-modern',
fixedHeader: true,
hover: true,
},
VChip: {
rounded: 'lg',
size: 'default',
class: 'chip-modern',
},
VDialog: {
class: 'dialog-modern',
maxWidth: '600',
},
VAlert: {
rounded: 'lg',
variant: 'tonal',
class: 'alert-modern',
},
VProgressLinear: {
rounded: true,
height: '6',
},
VProgressCircular: {
width: '3',
},
},
},
},
});

22313
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,57 @@
{ {
"name": "monacousa-portal-2026", "name": "monacousa-portal",
"private": true, "type": "module",
"version": "0.0.1", "scripts": {
"type": "module", "build": "nuxt build",
"scripts": { "dev": "nuxt dev",
"dev": "vite dev", "generate": "nuxt generate",
"build": "vite build", "preview": "nuxt preview",
"preview": "vite preview", "postinstall": "nuxt prepare",
"prepare": "svelte-kit sync || echo ''", "typecheck": "nuxt typecheck"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", },
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "dependencies": {
}, "@fullcalendar/core": "^6.1.19",
"devDependencies": { "@fullcalendar/daygrid": "^6.1.19",
"@sveltejs/adapter-auto": "^7.0.0", "@fullcalendar/interaction": "^6.1.19",
"@sveltejs/kit": "^2.50.0", "@fullcalendar/list": "^6.1.19",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@fullcalendar/vue3": "^6.1.19",
"@tailwindcss/vite": "^4.1.18", "@headlessui/vue": "^1.7.23",
"bits-ui": "^2.15.4", "@tailwindcss/forms": "^0.5.10",
"clsx": "^2.1.1", "@types/handlebars": "^4.0.40",
"lucide-svelte": "^0.562.0", "@types/jsonwebtoken": "^9.0.10",
"svelte": "^5.47.0", "@types/nodemailer": "^6.4.17",
"svelte-check": "^4.3.4", "@vite-pwa/nuxt": "^0.10.8",
"tailwind-merge": "^3.4.0", "@vueuse/core": "^13.8.0",
"tailwind-variants": "^3.2.2", "@vueuse/motion": "^3.0.3",
"tailwindcss": "^4.1.18", "autoprefixer": "^10.4.21",
"typescript": "^5.9.3", "chart.js": "^4.5.0",
"vite": "^7.3.1" "cookie": "^0.6.0",
}, "formidable": "^3.5.4",
"dependencies": { "framer-motion": "^12.23.12",
"@aws-sdk/client-s3": "^3.971.0", "gsap": "^3.13.0",
"@aws-sdk/s3-request-presigner": "^3.971.0", "handlebars": "^4.7.8",
"@internationalized/date": "^3.7.0", "jsonwebtoken": "^9.0.2",
"@supabase/ssr": "^0.8.0", "libphonenumber-js": "^1.12.10",
"@supabase/supabase-js": "^2.90.1", "lottie-web": "^5.13.0",
"@sveltejs/adapter-node": "^5.5.1", "lucide-vue-next": "^0.542.0",
"flag-icons": "^7.4.0", "mime-types": "^3.0.1",
"libphonenumber-js": "^1.12.8", "minio": "^8.0.5",
"nodemailer": "^6.10.0" "nodemailer": "^7.0.5",
} "nuxt": "^3.15.4",
"postcss": "^8.5.6",
"sharp": "^0.34.3",
"tailwindcss": "^4.1.12",
"vue": "latest",
"vue-chartjs": "^5.3.2",
"vue-country-flag-next": "^2.3.2",
"vue-router": "latest",
"vuetify-nuxt-module": "^0.18.3"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/formidable": "^3.4.5",
"@types/mime-types": "^3.0.1",
"@types/node": "^20.0.0",
"sass": "^1.91.0"
}
} }

View File

@ -0,0 +1,721 @@
<template>
<div class="admin-dashboard-v2">
<!-- Neumorphic Header -->
<div class="dashboard-header">
<h1 class="dashboard-title">System Administration</h1>
<p class="dashboard-subtitle">Complete platform control and management</p>
</div>
<!-- Stats Grid with Neumorphic Cards -->
<div class="stats-grid">
<div class="stat-card neumorphic-card" v-for="stat in stats" :key="stat.id">
<div class="stat-content">
<div class="stat-info">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-change" :class="stat.changeType">
<Icon :name="stat.changeIcon" class="change-icon" />
<span>{{ stat.changeText }}</span>
</div>
</div>
<div class="stat-icon-wrapper neumorphic-inset">
<Icon :name="stat.icon" class="stat-icon" :style="{ color: stat.color }" />
</div>
</div>
</div>
</div>
<!-- Main Management Sections -->
<div class="management-grid">
<!-- User Management -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:account-group" class="header-icon" />
<h2>User Management</h2>
</div>
<p class="card-description">Manage user accounts, roles, and permissions</p>
<!-- Morphing Dropdown for User Filters -->
<div class="morphing-select-wrapper">
<div class="select-trigger neumorphic-button" @click="toggleUserFilter">
<span>{{ selectedUserFilter }}</span>
<Icon name="mdi:chevron-down" class="dropdown-icon" :class="{ 'rotate': showUserFilter }" />
</div>
<Transition name="morph">
<div v-if="showUserFilter" class="morphing-dropdown">
<div
v-for="option in userFilterOptions"
:key="option"
class="dropdown-option"
@click="selectUserFilter(option)"
>
{{ option }}
</div>
</div>
</Transition>
</div>
<div class="action-buttons">
<button class="neumorphic-button primary" @click="showCreateUserDialog = true">
<Icon name="mdi:account-plus" />
Create User
</button>
<button class="neumorphic-button" @click="manageRoles">
<Icon name="mdi:shield-account" />
Manage Roles
</button>
</div>
</div>
<!-- System Maintenance -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:cog" class="header-icon" />
<h2>System Maintenance</h2>
</div>
<p class="card-description">Backend operations and system health</p>
<!-- System Status Indicator -->
<div class="system-status neumorphic-inset">
<div class="status-indicator" :class="systemStatus.type"></div>
<span>{{ systemStatus.text }}</span>
</div>
<div class="action-buttons">
<button class="neumorphic-button" @click="assignMemberIds">
Assign Member IDs
</button>
<button class="neumorphic-button" @click="backfillEventIds">
Backfill Event IDs
</button>
</div>
</div>
<!-- Reports & Analytics -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:chart-line" class="header-icon" />
<h2>Reports & Analytics</h2>
</div>
<p class="card-description">Generate insights and track metrics</p>
<!-- Report Type Dropdown -->
<div class="morphing-select-wrapper">
<div class="select-trigger neumorphic-button" @click="toggleReportType">
<span>{{ selectedReportType }}</span>
<Icon name="mdi:chevron-down" class="dropdown-icon" :class="{ 'rotate': showReportType }" />
</div>
<Transition name="morph">
<div v-if="showReportType" class="morphing-dropdown">
<div
v-for="type in reportTypes"
:key="type"
class="dropdown-option"
@click="selectReportType(type)"
>
{{ type }}
</div>
</div>
</Transition>
</div>
<button class="neumorphic-button primary full-width" @click="generateReport">
<Icon name="mdi:file-chart" />
Generate Report
</button>
</div>
<!-- Configuration -->
<div class="management-card neumorphic-card">
<div class="card-header">
<Icon name="mdi:tune" class="header-icon" />
<h2>Configuration</h2>
</div>
<p class="card-description">Portal settings and integrations</p>
<div class="config-grid">
<button class="config-button neumorphic-button" @click="showMembershipConfig = true">
<Icon name="mdi:card-account-details" />
<span>Membership</span>
</button>
<button class="config-button neumorphic-button" @click="showRecaptchaConfig = true">
<Icon name="mdi:robot" />
<span>reCAPTCHA</span>
</button>
<button class="config-button neumorphic-button" @click="openEmailConfig">
<Icon name="mdi:email" />
<span>Email</span>
</button>
<button class="config-button neumorphic-button" @click="showNocoDBSettings = true">
<Icon name="mdi:database" />
<span>Database</span>
</button>
</div>
</div>
</div>
<!-- Activity Feed -->
<div class="activity-section neumorphic-card">
<div class="card-header">
<Icon name="mdi:timeline" class="header-icon" />
<h2>Recent Activity</h2>
<button class="neumorphic-button small" @click="refreshActivity">
<Icon name="mdi:refresh" />
</button>
</div>
<div class="activity-list">
<div v-for="activity in recentActivity" :key="activity.id" class="activity-item neumorphic-inset">
<Icon :name="activity.icon" class="activity-icon" :style="{ color: activity.color }" />
<div class="activity-content">
<p class="activity-text">{{ activity.text }}</p>
<span class="activity-time">{{ activity.time }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
// Define page meta
definePageMeta({
layout: 'admin',
middleware: 'auth'
})
// Stats data
const stats = ref([
{
id: 1,
label: 'Total Members',
value: '1,247',
changeType: 'positive',
changeIcon: 'mdi:trending-up',
changeText: '+12% this month',
icon: 'mdi:account-group',
color: '#CC0000'
},
{
id: 2,
label: 'Active Sessions',
value: '342',
changeType: 'neutral',
changeIcon: 'mdi:circle',
changeText: 'Live now',
icon: 'mdi:monitor-dashboard',
color: '#10B981'
},
{
id: 3,
label: 'Revenue MTD',
value: '$48,392',
changeType: 'positive',
changeIcon: 'mdi:trending-up',
changeText: '+8% vs last month',
icon: 'mdi:currency-usd',
color: '#3B82F6'
},
{
id: 4,
label: 'System Health',
value: '98.5%',
changeType: 'positive',
changeIcon: 'mdi:check-circle',
changeText: 'All systems operational',
icon: 'mdi:shield-check',
color: '#10B981'
}
])
// Dropdown states
const showUserFilter = ref(false)
const selectedUserFilter = ref('All Users')
const userFilterOptions = ref(['All Users', 'Active Users', 'Inactive Users', 'Admins', 'Members'])
const showReportType = ref(false)
const selectedReportType = ref('Financial Report')
const reportTypes = ref(['Financial Report', 'Member Report', 'Activity Report', 'Usage Report'])
// System status
const systemStatus = ref({
type: 'healthy',
text: 'All systems operational'
})
// Recent activity
const recentActivity = ref([
{
id: 1,
icon: 'mdi:account-plus',
text: 'New member registration: John Doe',
time: '2 minutes ago',
color: '#10B981'
},
{
id: 2,
icon: 'mdi:credit-card',
text: 'Payment received from Jane Smith',
time: '15 minutes ago',
color: '#3B82F6'
},
{
id: 3,
icon: 'mdi:calendar-check',
text: 'Event created: Annual Gala 2024',
time: '1 hour ago',
color: '#F59E0B'
},
{
id: 4,
icon: 'mdi:account-edit',
text: 'Profile updated: Mike Johnson',
time: '3 hours ago',
color: '#6B7280'
}
])
// Dialog states
const showCreateUserDialog = ref(false)
const showMembershipConfig = ref(false)
const showRecaptchaConfig = ref(false)
const showNocoDBSettings = ref(false)
// Methods
const toggleUserFilter = () => {
showUserFilter.value = !showUserFilter.value
showReportType.value = false
}
const selectUserFilter = (option) => {
selectedUserFilter.value = option
showUserFilter.value = false
}
const toggleReportType = () => {
showReportType.value = !showReportType.value
showUserFilter.value = false
}
const selectReportType = (type) => {
selectedReportType.value = type
showReportType.value = false
}
const manageRoles = () => {
console.log('Managing roles...')
}
const assignMemberIds = () => {
console.log('Assigning member IDs...')
}
const backfillEventIds = () => {
console.log('Backfilling event IDs...')
}
const generateReport = () => {
console.log('Generating report:', selectedReportType.value)
}
const openEmailConfig = () => {
console.log('Opening email configuration...')
}
const refreshActivity = () => {
console.log('Refreshing activity...')
}
onMounted(() => {
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.morphing-select-wrapper')) {
showUserFilter.value = false
showReportType.value = false
}
})
})
</script>
<style lang="scss" scoped>
@import '@/assets/scss/design-system-v2.scss';
.admin-dashboard-v2 {
padding: 2rem;
background: linear-gradient(135deg, $neutral-50 0%, $neutral-100 100%);
min-height: 100vh;
}
// Header
.dashboard-header {
text-align: center;
margin-bottom: 3rem;
.dashboard-title {
font-size: $text-4xl;
font-weight: $font-bold;
color: $neutral-800;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, $primary-600, $primary-800);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dashboard-subtitle {
color: $neutral-600;
font-size: $text-lg;
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
@include neumorphic-card('md');
padding: 1.5rem;
transition: all $transition-base;
&:hover {
@include neumorphic-card('lg');
transform: translateY(-2px);
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-info {
flex: 1;
}
.stat-label {
color: $neutral-600;
font-size: $text-sm;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: $text-3xl;
font-weight: $font-bold;
color: $neutral-800;
margin-bottom: 0.5rem;
}
.stat-change {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: $text-sm;
&.positive {
color: $success-500;
}
&.neutral {
color: $neutral-600;
}
.change-icon {
width: 14px;
height: 14px;
}
}
.stat-icon-wrapper {
width: 60px;
height: 60px;
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-inset-sm;
.stat-icon {
width: 28px;
height: 28px;
}
}
}
// Management Grid
.management-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.management-card {
@include neumorphic-card('md');
padding: 2rem;
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
.header-icon {
width: 24px;
height: 24px;
color: $primary-600;
}
h2 {
font-size: $text-xl;
font-weight: $font-semibold;
color: $neutral-800;
}
}
.card-description {
color: $neutral-600;
margin-bottom: 1.5rem;
font-size: $text-sm;
}
.action-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.config-button {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
font-size: $text-sm;
svg {
width: 20px;
height: 20px;
}
}
}
// Morphing Dropdown
.morphing-select-wrapper {
position: relative;
margin-bottom: 1.5rem;
.select-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
.dropdown-icon {
transition: transform 0.3s $spring-smooth;
&.rotate {
transform: rotate(180deg);
}
}
}
}
.morphing-dropdown {
@include morphing-dropdown();
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: $z-dropdown;
.dropdown-option {
padding: 0.75rem 1rem;
cursor: pointer;
transition: all $transition-fast;
color: $neutral-700;
&:hover {
background: rgba($blue-500, 0.1);
color: $blue-600;
padding-left: 1.25rem;
}
}
}
// Neumorphic Elements
.neumorphic-card {
background: linear-gradient(145deg, #ffffff, #f0f0f0);
border-radius: $radius-xl;
box-shadow: $shadow-soft-md;
}
.neumorphic-button {
@include neumorphic-button();
padding: 0.75rem 1.5rem;
border: none;
border-radius: $radius-lg;
font-weight: $font-medium;
color: $neutral-700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&.primary {
background: linear-gradient(145deg, $primary-600, $primary-700);
color: white;
&:hover {
background: linear-gradient(145deg, $primary-700, $primary-800);
}
}
&.small {
padding: 0.5rem 0.75rem;
font-size: $text-sm;
}
&.full-width {
width: 100%;
justify-content: center;
}
svg {
width: 18px;
height: 18px;
}
}
.neumorphic-inset {
box-shadow: $shadow-inset-sm;
background: linear-gradient(145deg, #e6e6e6, #ffffff);
}
// System Status
.system-status {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: $radius-lg;
margin-bottom: 1.5rem;
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
&.healthy {
background-color: $success-500;
}
&.warning {
background-color: $warning-500;
}
&.error {
background-color: $error-500;
}
}
}
// Activity Section
.activity-section {
@include neumorphic-card('lg');
padding: 2rem;
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
h2 {
flex: 1;
}
}
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: $radius-lg;
.activity-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.activity-content {
flex: 1;
.activity-text {
color: $neutral-800;
font-size: $text-sm;
margin-bottom: 0.25rem;
}
.activity-time {
color: $neutral-500;
font-size: $text-xs;
}
}
}
}
// Transitions
.morph-enter-active,
.morph-leave-active {
transition: all 0.3s $spring-smooth;
}
.morph-enter-from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
.morph-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
// Responsive
@include responsive($breakpoint-md) {
.admin-dashboard-v2 {
padding: 3rem;
}
}
@include responsive($breakpoint-lg) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.management-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,552 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-6">
<v-col>
<h1 class="text-h3 font-weight-bold mb-2">Event Management</h1>
<p class="text-body-1 text-medium-emphasis">Create and manage association events</p>
</v-col>
<v-col cols="auto">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-calendar-plus"
@click="showCreateDialog = true"
>
Create Event
</v-btn>
</v-col>
</v-row>
<!-- Stats Cards -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.upcoming }}</div>
<div class="text-body-2 text-medium-emphasis">Upcoming Events</div>
</div>
<v-icon size="32" color="primary">mdi-calendar-clock</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.totalRegistrations }}</div>
<div class="text-body-2 text-medium-emphasis">Total Registrations</div>
</div>
<v-icon size="32" color="success">mdi-account-check</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">${{ stats.revenue }}</div>
<div class="text-body-2 text-medium-emphasis">Total Revenue</div>
</div>
<v-icon size="32" color="warning">mdi-cash</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h4 font-weight-bold">{{ stats.avgAttendance }}%</div>
<div class="text-body-2 text-medium-emphasis">Avg Attendance</div>
</div>
<v-icon size="32" color="info">mdi-chart-line</v-icon>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Filters -->
<v-card class="mb-6" elevation="0">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-text-field
v-model="searchQuery"
label="Search events"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="typeFilter"
label="Event Type"
:items="typeOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="dateRange"
label="Date Range"
:items="dateRangeOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Events List -->
<v-card elevation="2">
<v-data-table
:headers="headers"
:items="filteredEvents"
:search="searchQuery"
:loading="loading"
class="elevation-0"
hover
>
<template v-slot:item.title="{ item }">
<div class="py-2">
<div class="font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.type }}</div>
</div>
</template>
<template v-slot:item.date="{ item }">
<div>
<div class="text-body-2">{{ formatDate(item.date) }}</div>
<div class="text-caption text-medium-emphasis">{{ item.time }}</div>
</div>
</template>
<template v-slot:item.registrations="{ item }">
<div class="d-flex align-center">
<v-progress-linear
:model-value="(item.registrations / item.capacity) * 100"
:color="getCapacityColor(item.registrations, item.capacity)"
height="6"
rounded
class="mr-2"
style="min-width: 60px"
/>
<span class="text-body-2">
{{ item.registrations }}/{{ item.capacity }}
</span>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="tonal"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="viewEvent(item)"
/>
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editEvent(item)"
/>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
>
<v-menu activator="parent">
<v-list density="compact">
<v-list-item @click="duplicateEvent(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-content-copy</v-icon>
Duplicate
</v-list-item-title>
</v-list-item>
<v-list-item @click="viewAttendees(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-account-group</v-icon>
View Attendees
</v-list-item-title>
</v-list-item>
<v-list-item @click="exportEvent(item)">
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-download</v-icon>
Export Data
</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item
@click="cancelEvent(item)"
class="text-error"
:disabled="item.status === 'cancelled'"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-cancel</v-icon>
Cancel Event
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-data-table>
</v-card>
<!-- Create/Edit Event Dialog -->
<v-dialog v-model="showCreateDialog" max-width="800">
<v-card>
<v-card-title>
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
</v-card-title>
<v-card-text>
<v-form ref="eventForm">
<v-row>
<v-col cols="12">
<v-text-field
v-model="eventForm.title"
label="Event Title"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="eventForm.description"
label="Description"
variant="outlined"
rows="3"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventForm.type"
label="Event Type"
:items="typeOptions"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventForm.location"
label="Location"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.date"
label="Date"
type="date"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.time"
label="Time"
type="time"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.duration"
label="Duration (hours)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.capacity"
label="Capacity"
type="number"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="eventForm.price"
label="Price"
prefix="$"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="eventForm.registrationType"
label="Registration"
:items="['Open', 'Members Only', 'Invite Only']"
variant="outlined"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="flat" @click="saveEvent">
{{ editingEvent ? 'Update' : 'Create' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
// State
const loading = ref(false);
const showCreateDialog = ref(false);
const editingEvent = ref(null);
const searchQuery = ref('');
const statusFilter = ref(null);
const typeFilter = ref(null);
const dateRange = ref(null);
// Stats
const stats = ref({
upcoming: 8,
totalRegistrations: 342,
revenue: 15420,
avgAttendance: 78
});
// Form data
const eventForm = ref({
title: '',
description: '',
type: '',
location: '',
date: '',
time: '',
duration: 2,
capacity: 50,
price: 0,
registrationType: 'Open'
});
// Options
const statusOptions = [
'Upcoming',
'Ongoing',
'Completed',
'Cancelled'
];
const typeOptions = [
'Conference',
'Workshop',
'Networking',
'Social',
'Fundraiser',
'Meeting'
];
const dateRangeOptions = [
'This Week',
'This Month',
'Next Month',
'This Quarter',
'This Year'
];
// Table configuration
const headers = [
{ title: 'Event', key: 'title', sortable: true },
{ title: 'Date & Time', key: 'date', sortable: true },
{ title: 'Location', key: 'location', sortable: true },
{ title: 'Registrations', key: 'registrations', sortable: true },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
];
// Mock data
const events = ref([
{
id: 1,
title: 'Annual Gala Dinner',
type: 'Fundraiser',
date: new Date('2024-02-15'),
time: '19:00',
location: 'Grand Ballroom',
registrations: 145,
capacity: 200,
status: 'Upcoming',
price: 250
},
{
id: 2,
title: 'Business Networking Event',
type: 'Networking',
date: new Date('2024-01-22'),
time: '18:00',
location: 'Conference Center',
registrations: 48,
capacity: 50,
status: 'Upcoming',
price: 0
},
{
id: 3,
title: 'Digital Marketing Workshop',
type: 'Workshop',
date: new Date('2024-01-10'),
time: '14:00',
location: 'Training Room A',
registrations: 22,
capacity: 30,
status: 'Completed',
price: 75
},
{
id: 4,
title: 'Board Meeting',
type: 'Meeting',
date: new Date('2024-01-05'),
time: '10:00',
location: 'Board Room',
registrations: 12,
capacity: 15,
status: 'Completed',
price: 0
}
]);
// Computed
const filteredEvents = computed(() => {
let filtered = [...events.value];
if (statusFilter.value) {
filtered = filtered.filter(e => e.status === statusFilter.value);
}
if (typeFilter.value) {
filtered = filtered.filter(e => e.type === typeFilter.value);
}
return filtered;
});
// Methods
const getStatusColor = (status: string) => {
switch (status) {
case 'Upcoming': return 'info';
case 'Ongoing': return 'success';
case 'Completed': return 'default';
case 'Cancelled': return 'error';
default: return 'default';
}
};
const getCapacityColor = (registrations: number, capacity: number) => {
const percentage = (registrations / capacity) * 100;
if (percentage >= 90) return 'error';
if (percentage >= 70) return 'warning';
return 'success';
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const viewEvent = (event: any) => {
console.log('View event:', event);
};
const editEvent = (event: any) => {
editingEvent.value = event;
eventForm.value = {
title: event.title,
description: '',
type: event.type,
location: event.location,
date: event.date.toISOString().split('T')[0],
time: event.time,
duration: 2,
capacity: event.capacity,
price: event.price,
registrationType: 'Open'
};
showCreateDialog.value = true;
};
const duplicateEvent = (event: any) => {
console.log('Duplicate event:', event);
};
const viewAttendees = (event: any) => {
console.log('View attendees:', event);
};
const exportEvent = (event: any) => {
console.log('Export event:', event);
};
const cancelEvent = (event: any) => {
console.log('Cancel event:', event);
event.status = 'Cancelled';
};
const saveEvent = () => {
console.log('Save event:', eventForm.value);
showCreateDialog.value = false;
editingEvent.value = null;
};
</script>

Some files were not shown because too many files have changed in this diff Show More