Compare commits
No commits in common. "main" and "visual-audit/playwright" have entirely different histories.
main
...
visual-aud
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
146
.dockerignore
146
.dockerignore
|
|
@ -1,47 +1,133 @@
|
|||
# Dependencies
|
||||
# Node.js
|
||||
node_modules
|
||||
.pnpm-store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build output
|
||||
build
|
||||
.svelte-kit
|
||||
# Nuxt.js build output
|
||||
.output
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Environment files (we pass these at runtime)
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.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
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Supabase local
|
||||
supabase/.temp
|
||||
supabase/.branches
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitea/
|
||||
|
||||
# Misc
|
||||
# Documentation
|
||||
README.md
|
||||
docs/
|
||||
*.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/
|
||||
|
|
|
|||
112
.env.example
112
.env.example
|
|
@ -1,89 +1,39 @@
|
|||
# Monaco USA Portal - Docker Environment Configuration
|
||||
# ===================================================
|
||||
# Copy this file to .env and configure your values
|
||||
# Example Environment
|
||||
|
||||
# ===========================================
|
||||
# POSTGRES DATABASE
|
||||
# ===========================================
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-this-to-a-secure-password
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_PORT=5435
|
||||
# Server Configuration
|
||||
NUXT_PORT=6060
|
||||
NUXT_HOST=0.0.0.0
|
||||
|
||||
# ===========================================
|
||||
# JWT CONFIGURATION
|
||||
# ===========================================
|
||||
# IMPORTANT: Generate a new secret for production!
|
||||
# Use: openssl rand -base64 32
|
||||
JWT_SECRET=generate-a-new-secret-at-least-32-characters
|
||||
JWT_EXPIRY=3600
|
||||
# Keycloak Configuration
|
||||
NUXT_KEYCLOAK_ISSUER=https://auth.monacousa.org/realms/monacousa
|
||||
NUXT_KEYCLOAK_CLIENT_ID=monacousa-portal
|
||||
NUXT_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
NUXT_KEYCLOAK_CALLBACK_URL=https://portal.monacousa.org/auth/callback
|
||||
|
||||
# ===========================================
|
||||
# API KEYS
|
||||
# ===========================================
|
||||
# Generate these at: https://supabase.com/docs/guides/self-hosting#api-keys
|
||||
# They must be signed with your JWT_SECRET
|
||||
# Keycloak Admin Configuration (for password reset and admin operations)
|
||||
NUXT_KEYCLOAK_ADMIN_CLIENT_ID=admin-cli
|
||||
NUXT_KEYCLOAK_ADMIN_CLIENT_SECRET=your-admin-cli-client-secret
|
||||
|
||||
# Anonymous key - for public access (limited permissions)
|
||||
ANON_KEY=your-generated-anon-key
|
||||
# Cookie Configuration
|
||||
COOKIE_DOMAIN=.monacousa.org
|
||||
|
||||
# Service role key - for admin access (full permissions, keep secret!)
|
||||
SERVICE_ROLE_KEY=your-generated-service-role-key
|
||||
# NocoDB Configuration
|
||||
NUXT_NOCODB_URL=https://db.monacousa.org
|
||||
NUXT_NOCODB_TOKEN=your-nocodb-token
|
||||
NUXT_NOCODB_BASE_ID=your-nocodb-base-id
|
||||
|
||||
# ===========================================
|
||||
# URLS & PORTS
|
||||
# ===========================================
|
||||
KONG_HTTP_PORT=7455
|
||||
KONG_HTTPS_PORT=7456
|
||||
STUDIO_PORT=7454
|
||||
PORTAL_PORT=7453
|
||||
# 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
|
||||
|
||||
SITE_URL=http://localhost:7453
|
||||
API_EXTERNAL_URL=http://localhost:7455
|
||||
SUPABASE_PUBLIC_URL=http://localhost:7455
|
||||
# Security Configuration
|
||||
NUXT_SESSION_SECRET=your-48-character-session-secret-key-here
|
||||
NUXT_ENCRYPTION_KEY=your-32-character-encryption-key-here
|
||||
|
||||
PUBLIC_SUPABASE_URL=http://localhost:7455
|
||||
PUBLIC_SUPABASE_ANON_KEY=same-as-anon-key-above
|
||||
|
||||
# Service role key for admin operations (server-side only)
|
||||
SUPABASE_SERVICE_ROLE_KEY=same-as-service-role-key-above
|
||||
|
||||
# ===========================================
|
||||
# AUTH CONFIGURATION
|
||||
# ===========================================
|
||||
DISABLE_SIGNUP=false
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true
|
||||
ADDITIONAL_REDIRECT_URLS=http://localhost:7453/auth/callback
|
||||
|
||||
# ===========================================
|
||||
# SMTP EMAIL (Optional)
|
||||
# ===========================================
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_ADMIN_EMAIL=noreply@example.org
|
||||
SMTP_SENDER_NAME=Monaco USA
|
||||
|
||||
MAILER_URLPATHS_INVITE=/auth/verify
|
||||
MAILER_URLPATHS_CONFIRMATION=/auth/verify
|
||||
MAILER_URLPATHS_RECOVERY=/auth/verify
|
||||
MAILER_URLPATHS_EMAIL_CHANGE=/auth/verify
|
||||
RATE_LIMIT_EMAIL_SENT=100
|
||||
|
||||
# ===========================================
|
||||
# REALTIME
|
||||
# ===========================================
|
||||
SECRET_KEY_BASE=generate-a-new-secret-key-base
|
||||
|
||||
# ===========================================
|
||||
# POSTGREST
|
||||
# ===========================================
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
# ===========================================
|
||||
# SVELTEKIT CONFIGURATION
|
||||
# ===========================================
|
||||
# Body size limit for file uploads (avatars, documents)
|
||||
# 50MB = 52428800 bytes
|
||||
BODY_SIZE_LIMIT=52428800
|
||||
# Public Configuration
|
||||
NUXT_PUBLIC_DOMAIN=https://portal.monacousa.org
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,23 +1,44 @@
|
|||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
# Logs
|
||||
*.log*
|
||||
|
||||
# OS
|
||||
# Misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Env
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
!.env.docker
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.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/
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/cache
|
||||
Binary file not shown.
|
|
@ -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"
|
||||
3710
ARCHITECTURE.md
3710
ARCHITECTURE.md
File diff suppressed because it is too large
Load Diff
230
DEPLOYMENT.md
230
DEPLOYMENT.md
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
95
Dockerfile
95
Dockerfile
|
|
@ -1,77 +1,34 @@
|
|||
# Monaco USA Portal - SvelteKit Application
|
||||
# Multi-stage build for optimized production image
|
||||
ARG NODE_VERSION=22.12.0
|
||||
ARG PORT=6060
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
FROM node:${NODE_VERSION}-slim as base
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 sveltekit
|
||||
FROM base as build
|
||||
COPY package.json .
|
||||
COPY package-lock.json .
|
||||
RUN npm install --production=false
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/build ./build
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json
|
||||
FROM base as production
|
||||
ENV PORT=$PORT
|
||||
COPY --from=build /app/.output /app/.output
|
||||
COPY --from=build /app/server/templates /app/server/templates
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
# Copy debug entrypoint script
|
||||
COPY docker-entrypoint-debug.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint-debug.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
# Add health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:6060/api/health || exit 1
|
||||
|
||||
# Set runtime environment variables
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
# Install curl and net-tools for health check and debugging
|
||||
RUN apt-get update && apt-get install -y curl net-tools wget && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "build"]
|
||||
# Use debug entrypoint
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint-debug.sh"]
|
||||
|
|
|
|||
|
|
@ -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
144
README.md
|
|
@ -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
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
### 2. `CLINE_WORKSPACE_RULES.md`
|
||||
**Cline workspace configuration file** containing:
|
||||
- 🔧 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
|
||||
npx sv create my-app
|
||||
```
|
||||
### 3. `DOCKER_DEPLOYMENT_GUIDE.md`
|
||||
**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
|
||||
npm run dev
|
||||
2. **Set up Cline workspace rules**:
|
||||
- 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
|
||||
npm run dev -- --open
|
||||
```
|
||||
## 🎯 Project Specifications
|
||||
|
||||
## 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
|
||||
npm run build
|
||||
```
|
||||
Following this implementation guide will create a complete portal foundation with:
|
||||
|
||||
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!** 🚀
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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
339
deploy.sh
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,318 +1,79 @@
|
|||
# Monaco USA Portal - Full Stack Docker Compose
|
||||
# Includes: PostgreSQL, Supabase Services, and SvelteKit App
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# PostgreSQL Database
|
||||
# ============================================
|
||||
db:
|
||||
image: supabase/postgres:15.8.1.060
|
||||
container_name: monacousa-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5435}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
- ./supabase/migrations:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Supabase Studio (Dashboard)
|
||||
# ============================================
|
||||
studio:
|
||||
image: supabase/studio:20241202-71e5240
|
||||
container_name: monacousa-studio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${STUDIO_PORT:-7454}:3000"
|
||||
environment:
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
DEFAULT_ORGANIZATION_NAME: Monaco USA
|
||||
DEFAULT_PROJECT_NAME: Monaco USA Portal
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:7455}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
depends_on:
|
||||
meta:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Kong API Gateway
|
||||
# ============================================
|
||||
kong:
|
||||
image: kong:2.8.1
|
||||
container_name: monacousa-kong
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${KONG_HTTP_PORT:-7455}:8000"
|
||||
- "${KONG_HTTPS_PORT:-7456}:8443"
|
||||
environment:
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
volumes:
|
||||
- ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
|
||||
depends_on:
|
||||
auth:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# GoTrue (Auth)
|
||||
# ============================================
|
||||
auth:
|
||||
image: supabase/gotrue:v2.164.0
|
||||
container_name: monacousa-auth
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:7455}
|
||||
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}?search_path=auth
|
||||
|
||||
GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
|
||||
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
|
||||
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-noreply@monacousa.org}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Monaco USA}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE:-/auth/verify}
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION:-/auth/verify}
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY:-/auth/verify}
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE:-/auth/verify}
|
||||
|
||||
GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT:-100}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# PostgREST (REST API)
|
||||
# ============================================
|
||||
rest:
|
||||
image: postgrest/postgrest:v12.2.0
|
||||
container_name: monacousa-rest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public,storage,graphql_public}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exit 0"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Realtime
|
||||
# ============================================
|
||||
realtime:
|
||||
image: supabase/realtime:v2.33.58
|
||||
container_name: monacousa-realtime
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 4000
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: supabase_admin
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
DB_NAME: ${POSTGRES_DB:-postgres}
|
||||
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||
DB_ENC_KEY: supabaserealtime
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
DNS_NODES: "''"
|
||||
RLIMIT_NOFILE: "10000"
|
||||
APP_NAME: realtime
|
||||
SEED_SELF_HOST: true
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Storage API
|
||||
# ============================================
|
||||
storage:
|
||||
image: supabase/storage-api:v1.11.13
|
||||
container_name: monacousa-storage
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
|
||||
FILE_SIZE_LIMIT: 52428800
|
||||
STORAGE_BACKEND: file
|
||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||
TENANT_ID: stub
|
||||
REGION: stub
|
||||
GLOBAL_S3_BUCKET: stub
|
||||
ENABLE_IMAGE_TRANSFORMATION: "true"
|
||||
IMGPROXY_URL: http://imgproxy:8080
|
||||
volumes:
|
||||
- storage-data:/var/lib/storage
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Image Proxy (for storage transformations)
|
||||
# ============================================
|
||||
imgproxy:
|
||||
image: darthsim/imgproxy:v3.8.0
|
||||
container_name: monacousa-imgproxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
IMGPROXY_BIND: ":8080"
|
||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
|
||||
volumes:
|
||||
- storage-data:/var/lib/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "imgproxy", "health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Postgres Meta (for Studio)
|
||||
# ============================================
|
||||
meta:
|
||||
image: supabase/postgres-meta:v0.84.2
|
||||
container_name: monacousa-meta
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: db
|
||||
PG_META_DB_PORT: 5432
|
||||
PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
|
||||
PG_META_DB_USER: supabase_admin
|
||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exit 0"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- monacousa-network
|
||||
|
||||
# ============================================
|
||||
# Monaco USA Portal (SvelteKit App)
|
||||
# ============================================
|
||||
portal:
|
||||
monacousa-portal:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
container_name: monacousa-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORTAL_PORT:-7453}:3000"
|
||||
- "6060:6060"
|
||||
volumes:
|
||||
# Volume for persistent data (environment files, logs, etc.)
|
||||
- ./data:/app/data
|
||||
# Optional: Mount logs directory
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SUPABASE_INTERNAL_URL: http://kong:8000
|
||||
NODE_ENV: production
|
||||
ORIGIN: http://localhost:7453
|
||||
# Body size limit for file uploads (50MB)
|
||||
BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800}
|
||||
depends_on:
|
||||
kong:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Basic configuration
|
||||
- NODE_ENV=production
|
||||
- NUXT_HOST=0.0.0.0
|
||||
- NUXT_PORT=6060
|
||||
|
||||
# Keycloak Configuration (override with your values)
|
||||
- NUXT_KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-https://auth.monacousa.org/realms/monacousa-portal}
|
||||
- NUXT_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-monacousa-portal}
|
||||
- NUXT_KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET}
|
||||
- NUXT_KEYCLOAK_CALLBACK_URL=${KEYCLOAK_CALLBACK_URL:-https://monacousa.org/auth/callback}
|
||||
|
||||
# NocoDB Configuration
|
||||
- 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:
|
||||
- monacousa-network
|
||||
|
||||
# Resource limits (adjust as needed)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
|
||||
# ============================================
|
||||
# Networks
|
||||
# ============================================
|
||||
networks:
|
||||
monacousa-network:
|
||||
driver: bridge
|
||||
|
||||
# ============================================
|
||||
# Volumes
|
||||
# ============================================
|
||||
volumes:
|
||||
db-data:
|
||||
portal-data:
|
||||
driver: local
|
||||
storage-data:
|
||||
portal-logs:
|
||||
driver: local
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return navigateTo('/login');
|
||||
}
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
94
package.json
94
package.json
|
|
@ -1,41 +1,57 @@
|
|||
{
|
||||
"name": "monacousa-portal-2026",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"svelte": "^5.47.0",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.971.0",
|
||||
"@internationalized/date": "^3.7.0",
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@sveltejs/adapter-node": "^5.5.1",
|
||||
"flag-icons": "^7.4.0",
|
||||
"libphonenumber-js": "^1.12.8",
|
||||
"nodemailer": "^6.10.0"
|
||||
}
|
||||
"name": "monacousa-portal",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.19",
|
||||
"@fullcalendar/daygrid": "^6.1.19",
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/list": "^6.1.19",
|
||||
"@fullcalendar/vue3": "^6.1.19",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@types/handlebars": "^4.0.40",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@vite-pwa/nuxt": "^0.10.8",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"chart.js": "^4.5.0",
|
||||
"cookie": "^0.6.0",
|
||||
"formidable": "^3.5.4",
|
||||
"framer-motion": "^12.23.12",
|
||||
"gsap": "^3.13.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libphonenumber-js": "^1.12.10",
|
||||
"lottie-web": "^5.13.0",
|
||||
"lucide-vue-next": "^0.542.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"minio": "^8.0.5",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue