Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
180
PLATFORM_INTEGRATION.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Platform Integration Summary
|
||||
|
||||
## What We've Completed
|
||||
|
||||
### ✅ Backend (Django)
|
||||
|
||||
1. **Created Platform App** (`smoothschedule/platform/`)
|
||||
- `apps.py` - App configuration
|
||||
- `serializers.py` - TenantSerializer, PlatformUserSerializer, PlatformMetricsSerializer
|
||||
- `permissions.py` - IsPlatformAdmin, IsPlatformUser
|
||||
- `views.py` - TenantViewSet, PlatformUserViewSet
|
||||
- `urls.py` - URL routing
|
||||
|
||||
2. **API Endpoints Created**
|
||||
- `GET /api/platform/businesses/` - List all tenants
|
||||
- `GET /api/platform/businesses/{id}/` - Get specific tenant
|
||||
- `GET /api/platform/businesses/metrics/` - Get platform metrics
|
||||
- `GET /api/platform/users/` - List all users
|
||||
- `GET /api/platform/users/{id}/` - Get specific user
|
||||
|
||||
3. **Configuration Updates**
|
||||
- Added `platform` to `INSTALLED_APPS` in `config/settings/base.py`
|
||||
- Added platform URLs to `config/urls.py`
|
||||
|
||||
### ✅ Frontend (React)
|
||||
|
||||
The frontend already has complete implementation from the tarball:
|
||||
|
||||
1. **Platform Pages**
|
||||
- `pages/platform/PlatformDashboard.tsx` - Metrics and charts
|
||||
- `pages/platform/PlatformBusinesses.tsx` - Tenant list with masquerade
|
||||
- `pages/platform/PlatformUsers.tsx` - User management
|
||||
- `pages/platform/PlatformSupport.tsx` - Support tickets
|
||||
- `pages/platform/PlatformSettings.tsx` - Platform settings
|
||||
|
||||
2. **Routing**
|
||||
- Role-based access configured in `App.tsx`
|
||||
- Routes protected by user role (SUPERUSER, PLATFORM_MANAGER, etc.)
|
||||
|
||||
3. **API Integration**
|
||||
- `hooks/usePlatform.ts` - React Query hooks
|
||||
- `api/platform.ts` - API client functions
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Restart Django Server
|
||||
The Django server needs to be restarted to pick up the new `platform` app:
|
||||
|
||||
```bash
|
||||
# Stop the current server (Ctrl+C in the terminal)
|
||||
# Then restart it
|
||||
docker-compose -f docker-compose.local.yml restart django
|
||||
```
|
||||
|
||||
### 2. Test API Endpoints
|
||||
|
||||
Once the server is restarted, test the endpoints:
|
||||
|
||||
```bash
|
||||
# List all tenants (requires authentication)
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://lvh.me:8000/api/platform/businesses/
|
||||
|
||||
# List all users
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://lvh.me:8000/api/platform/users/
|
||||
```
|
||||
|
||||
### 3. Create Platform Admin User
|
||||
|
||||
You need a user with platform admin role to access these endpoints:
|
||||
|
||||
```python
|
||||
# In Django shell
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Create or update a superuser
|
||||
user = User.objects.filter(username='admin').first()
|
||||
if user:
|
||||
user.role = User.Role.SUPERUSER
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
```
|
||||
|
||||
### 4. Test Frontend
|
||||
|
||||
1. Navigate to `http://platform.lvh.me:5173/`
|
||||
2. Login with platform admin credentials
|
||||
3. You should see:
|
||||
- Platform Dashboard with metrics
|
||||
- Businesses page showing all tenants
|
||||
- Users page showing all users
|
||||
- Support and Settings pages
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Need to Add Tenant Reference to User Model
|
||||
|
||||
Currently, the `User` model doesn't have a direct reference to which `Tenant` they belong to. This is needed for:
|
||||
- Showing business name in user list
|
||||
- Filtering users by business
|
||||
- Getting accurate owner information
|
||||
|
||||
**To fix:** Add a foreign key to `User` model:
|
||||
|
||||
```python
|
||||
# In smoothschedule/users/models.py
|
||||
from core.models import Tenant
|
||||
|
||||
class User(AbstractUser):
|
||||
# ... existing fields ...
|
||||
|
||||
tenant = models.ForeignKey(
|
||||
Tenant,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='users',
|
||||
help_text="Tenant this user belongs to (null for platform users)"
|
||||
)
|
||||
```
|
||||
|
||||
Then run migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Multi-Tenancy with django-tenants
|
||||
|
||||
- **Public Schema**: Stores `Tenant` and `Domain` models
|
||||
- **Tenant Schemas**: Each tenant gets isolated PostgreSQL schema
|
||||
- **Platform API**: Operates on public schema to list all tenants
|
||||
- **User Model**: Stored in public schema (shared across tenants)
|
||||
|
||||
### Permission Model
|
||||
|
||||
1. **Platform Roles** (access all tenants):
|
||||
- `SUPERUSER` - Full access
|
||||
- `PLATFORM_MANAGER` - Manage tenants and users
|
||||
- `PLATFORM_SUPPORT` - View-only access
|
||||
- `PLATFORM_SALES` - Sales operations
|
||||
|
||||
2. **Tenant Roles** (access single tenant):
|
||||
- `TENANT_OWNER` - Tenant administrator
|
||||
- `TENANT_MANAGER` - Manage tenant settings
|
||||
- `TENANT_STAFF` - Use tenant features
|
||||
|
||||
3. **Customer Role**:
|
||||
- `CUSTOMER` - End user of tenant
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── platform/ # NEW - Platform management app
|
||||
│ ├── __init__.py
|
||||
│ ├── apps.py
|
||||
│ ├── serializers.py # API serializers
|
||||
│ ├── permissions.py # Custom permissions
|
||||
│ ├── views.py # ViewSets
|
||||
│ └── urls.py # URL routing
|
||||
├── core/
|
||||
│ └── models.py # Tenant, Domain models
|
||||
├── smoothschedule/users/
|
||||
│ └── models.py # User model
|
||||
└── config/
|
||||
├── settings/base.py # Updated INSTALLED_APPS
|
||||
└── urls.py # Added platform URLs
|
||||
```
|
||||
|
||||
## Testing the Complete Flow
|
||||
|
||||
1. **Backend**: Platform API serving tenant/user data
|
||||
2. **Frontend**: React app consuming API and displaying in UI
|
||||
3. **Authentication**: Role-based access control
|
||||
4. **Multi-tenancy**: Proper isolation between tenants
|
||||
|
||||
Once the server is restarted and a platform admin user is created, the entire platform section should be functional!
|
||||
294
README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Smooth Schedule - Multi-Tenant SaaS Platform
|
||||
|
||||
A production-grade Django skeleton with **strict data isolation** and **high-trust security** for resource orchestration.
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
- ✅ **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||
- ✅ **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
|
||||
- ✅ **Secure Masquerading**: django-hijack with custom permission matrix
|
||||
- ✅ **Full Audit Trail**: Structured logging of all masquerade activity
|
||||
- ✅ **Headless API**: Django Rest Framework (no server-side HTML)
|
||||
- ✅ **Docker Ready**: Complete Docker Compose setup via cookiecutter-django
|
||||
- ✅ **AWS Integration**: S3 storage + Route53 DNS for custom domains
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- PostgreSQL 14+
|
||||
- Docker & Docker Compose
|
||||
- Cookiecutter (`pip install cookiecutter`)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Run Setup Script
|
||||
|
||||
```bash
|
||||
chmod +x setup_project.sh
|
||||
./setup_project.sh
|
||||
cd smoothschedule
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```env
|
||||
# Database
|
||||
POSTGRES_DB=smoothschedule_db
|
||||
POSTGRES_USER=smoothschedule_user
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
|
||||
# Django
|
||||
DJANGO_SECRET_KEY=your_secret_key_here
|
||||
DJANGO_DEBUG=True
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# AWS
|
||||
AWS_ACCESS_KEY_ID=your_aws_key
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret
|
||||
AWS_STORAGE_BUCKET_NAME=smoothschedule-media
|
||||
AWS_ROUTE53_HOSTED_ZONE_ID=your_zone_id
|
||||
```
|
||||
|
||||
### 3. Start Services
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
```bash
|
||||
# Shared schema
|
||||
docker-compose run --rm django python manage.py migrate_schemas --shared
|
||||
|
||||
# Create superuser
|
||||
docker-compose run --rm django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 5. Create First Tenant
|
||||
|
||||
```python
|
||||
docker-compose run --rm django python manage.py shell
|
||||
|
||||
from core.models import Tenant, Domain
|
||||
|
||||
tenant = Tenant.objects.create(
|
||||
name="Demo Company",
|
||||
schema_name="demo",
|
||||
subscription_tier="PROFESSIONAL",
|
||||
)
|
||||
|
||||
Domain.objects.create(
|
||||
domain="demo.smoothschedule.local",
|
||||
tenant=tenant,
|
||||
is_primary=True,
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run tenant migrations
|
||||
docker-compose run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Multi-Tenancy Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
├─────────────────────────────────────────┤
|
||||
│ public (shared schema) │
|
||||
│ ├─ Tenants │
|
||||
│ ├─ Domains │
|
||||
│ ├─ Users │
|
||||
│ └─ PermissionGrants │
|
||||
├─────────────────────────────────────────┤
|
||||
│ tenant_demo (schema for Demo Company) │
|
||||
│ ├─ Appointments │
|
||||
│ ├─ Resources │
|
||||
│ └─ Customers │
|
||||
├─────────────────────────────────────────┤
|
||||
│ tenant_acme (schema for Acme Corp) │
|
||||
│ ├─ Appointments │
|
||||
│ ├─ Resources │
|
||||
│ └─ Customers │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
| Role | Level | Access Scope |
|
||||
|---------------------|----------|---------------------------|
|
||||
| SUPERUSER | Platform | All tenants (god mode) |
|
||||
| PLATFORM_MANAGER | Platform | All tenants |
|
||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||
| PLATFORM_SUPPORT | Platform | Tenant users |
|
||||
| TENANT_OWNER | Tenant | Own tenant (full access) |
|
||||
| TENANT_MANAGER | Tenant | Own tenant |
|
||||
| TENANT_STAFF | Tenant | Own tenant (limited) |
|
||||
| CUSTOMER | Tenant | Own data only |
|
||||
|
||||
### Masquerading Matrix
|
||||
|
||||
| Hijacker Role | Can Masquerade As |
|
||||
|--------------------|----------------------------------|
|
||||
| SUPERUSER | Anyone |
|
||||
| PLATFORM_SUPPORT | Tenant users |
|
||||
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
|
||||
| TENANT_OWNER | Staff in same tenant |
|
||||
| Others | No one |
|
||||
|
||||
**Security Rules:**
|
||||
- Cannot hijack yourself
|
||||
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
|
||||
- Maximum depth: 1 (no hijack chains)
|
||||
- All attempts logged to `logs/masquerade.log`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── config/
|
||||
│ └── settings.py # Multi-tenancy & security config
|
||||
├── core/
|
||||
│ ├── models.py # Tenant, Domain, PermissionGrant
|
||||
│ ├── permissions.py # Hijack permission matrix
|
||||
│ ├── middleware.py # Masquerade audit logging
|
||||
│ └── admin.py # Django admin for core models
|
||||
├── users/
|
||||
│ ├── models.py # Custom User with 8-tier roles
|
||||
│ └── admin.py # User admin with hijack button
|
||||
├── logs/
|
||||
│ ├── security.log # General security events
|
||||
│ └── masquerade.log # Hijack activity (JSON)
|
||||
└── setup_project.sh # Automated setup script
|
||||
```
|
||||
|
||||
## 🔐 Security Features
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All masquerade activity is logged in JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"action": "HIJACK_START",
|
||||
"hijacker_email": "support@smoothschedule.com",
|
||||
"hijacked_email": "customer@demo.com",
|
||||
"ip_address": "192.168.1.1",
|
||||
"session_key": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Grants (30-Minute Window)
|
||||
|
||||
Time-limited elevated permissions:
|
||||
|
||||
```python
|
||||
from core.models import PermissionGrant
|
||||
|
||||
grant = PermissionGrant.create_grant(
|
||||
grantor=admin_user,
|
||||
grantee=support_user,
|
||||
action="view_billing",
|
||||
reason="Customer requested billing support",
|
||||
duration_minutes=30,
|
||||
)
|
||||
|
||||
# Check if active
|
||||
if grant.is_active():
|
||||
# Perform privileged action
|
||||
pass
|
||||
```
|
||||
|
||||
## 🧪 Testing Masquerading
|
||||
|
||||
1. Access Django Admin: `http://localhost:8000/admin/`
|
||||
2. Create test users with different roles
|
||||
3. Click "Hijack" button next to a user
|
||||
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
|
||||
|
||||
## 📊 Admin Interface
|
||||
|
||||
- **Tenant Management**: View tenants, domains, subscription tiers
|
||||
- **User Management**: Color-coded roles, masquerade buttons
|
||||
- **Permission Grants**: Active/expired/revoked status, bulk revoke
|
||||
- **Domain Verification**: AWS Route53 integration status
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Adding Tenant Apps
|
||||
|
||||
Edit `config/settings.py`:
|
||||
|
||||
```python
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'appointments', # Your app
|
||||
'resources', # Your app
|
||||
'billing', # Your app
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Domain Setup
|
||||
|
||||
```python
|
||||
domain = Domain.objects.create(
|
||||
domain="app.customdomain.com",
|
||||
tenant=tenant,
|
||||
is_custom_domain=True,
|
||||
route53_zone_id="Z1234567890ABC",
|
||||
)
|
||||
```
|
||||
|
||||
Then configure Route53 CNAME: `app.customdomain.com` → `smoothschedule.yourhost.com`
|
||||
|
||||
## 📖 Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `setup_project.sh` | Automated project initialization |
|
||||
| `config/settings.py` | Multi-tenancy, middleware, security config |
|
||||
| `core/models.py` | Tenant, Domain, PermissionGrant models |
|
||||
| `core/permissions.py` | Masquerading permission matrix |
|
||||
| `core/middleware.py` | Audit logging for masquerading |
|
||||
| `users/models.py` | Custom User with 8-tier roles |
|
||||
|
||||
## 📝 Important Notes
|
||||
|
||||
- **Django Admin**: The ONLY HTML interface (everything else is API)
|
||||
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
|
||||
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
|
||||
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**Cannot create tenant users:**
|
||||
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
|
||||
- Solution: Set `user.tenant = tenant_instance` before saving
|
||||
|
||||
**Hijack button doesn't appear:**
|
||||
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
|
||||
- Verify `HijackUserAdminMixin` in `users/admin.py`
|
||||
- Ensure user has permission per matrix rules
|
||||
|
||||
**Migrations fail:**
|
||||
- Run shared migrations first: `migrate_schemas --shared`
|
||||
- Then run tenant migrations: `migrate_schemas`
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for multi-tenant SaaS perfection**
|
||||
302
config/settings.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Smooth Schedule Settings - Multi-Tenancy & Security Configuration
|
||||
CRITICAL: This file contains essential settings for django-tenants and security middleware
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# MULTI-TENANCY CONFIGURATION (django-tenants)
|
||||
# =============================================================================
|
||||
|
||||
# Shared apps - Available to all tenants (stored in 'public' schema)
|
||||
SHARED_APPS = [
|
||||
'django_tenants', # Must be first
|
||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
'users', # Custom User model - shared across all tenants
|
||||
|
||||
# Django built-ins
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Third-party shared apps
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'hijack', # Masquerading
|
||||
'hijack.contrib.admin',
|
||||
|
||||
# Celery
|
||||
'django_celery_beat',
|
||||
'django_celery_results',
|
||||
]
|
||||
|
||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||
TENANT_APPS = [
|
||||
# Core tenant functionality
|
||||
'django.contrib.contenttypes', # Needed for tenant schemas
|
||||
|
||||
# Your tenant-scoped apps (add your business logic apps here)
|
||||
# Examples:
|
||||
# 'schedule', # Resource scheduling
|
||||
# 'customers', # Customer management
|
||||
# 'payments', # Billing & payments
|
||||
# 'appointments', # Appointment booking
|
||||
# 'analytics', # Tenant-specific analytics
|
||||
]
|
||||
|
||||
# Combined installed apps (django-tenants will handle schema routing)
|
||||
INSTALLED_APPS = list(SHARED_APPS) + [
|
||||
app for app in TENANT_APPS if app not in SHARED_APPS
|
||||
]
|
||||
|
||||
# Tenant model configuration
|
||||
TENANT_MODEL = "core.Tenant"
|
||||
TENANT_DOMAIN_MODEL = "core.Domain"
|
||||
|
||||
# Public schema name (for shared data)
|
||||
PUBLIC_SCHEMA_NAME = 'public'
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django_tenants.postgresql_backend', # Multi-schema engine
|
||||
'NAME': 'smoothschedule_db',
|
||||
'USER': 'smoothschedule_user',
|
||||
'PASSWORD': 'CHANGE_ME_IN_PRODUCTION', # Use env vars in production
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
|
||||
# Database routers for tenant isolation
|
||||
DATABASE_ROUTERS = [
|
||||
'django_tenants.routers.TenantSyncRouter',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# MIDDLEWARE CONFIGURATION
|
||||
# =============================================================================
|
||||
# CRITICAL: Order matters! Read comments carefully.
|
||||
|
||||
MIDDLEWARE = [
|
||||
# 1. MUST BE FIRST: Tenant resolution
|
||||
'django_tenants.middleware.main.TenantMainMiddleware',
|
||||
|
||||
# 2. Security middleware
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware', # Static files
|
||||
|
||||
# 3. Session & CSRF
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
||||
# 4. Authentication
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
|
||||
# 5. Hijack (Masquerading) - MUST come before our audit middleware
|
||||
'hijack.middleware.HijackUserMiddleware',
|
||||
|
||||
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
||||
# This is our custom middleware that logs masquerading activity
|
||||
'core.middleware.MasqueradeAuditMiddleware',
|
||||
|
||||
# 7. Messages & Clickjacking
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION & USER MODEL
|
||||
# =============================================================================
|
||||
|
||||
AUTH_USER_MODEL = 'users.User' # Custom user model with roles
|
||||
|
||||
# Hijack (Masquerading) Configuration
|
||||
HIJACK_AUTHORIZATION_CHECK = 'core.permissions.can_hijack'
|
||||
HIJACK_DISPLAY_ADMIN_BUTTON = True # Show hijack button in admin
|
||||
HIJACK_USE_BOOTSTRAP = True
|
||||
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST for hijacking
|
||||
|
||||
# Track when hijack sessions start (for audit duration calculation)
|
||||
HIJACK_INSERT_BEFORE = True
|
||||
|
||||
# =============================================================================
|
||||
# REST FRAMEWORK CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
# Add BrowsableAPIRenderer only in development
|
||||
# 'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# LOGGING CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{levelname}] {asctime} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'json': {
|
||||
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
||||
'format': '%(asctime)s %(name)s %(levelname)s %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'security_file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/security.log',
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
},
|
||||
'masquerade_file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/masquerade.log',
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
# General security logger
|
||||
'smoothschedule.security': {
|
||||
'handlers': ['console', 'security_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Masquerade-specific logger
|
||||
'smoothschedule.security.masquerade': {
|
||||
'handlers': ['console', 'masquerade_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Django default
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# AWS CONFIGURATION (S3, Route53)
|
||||
# =============================================================================
|
||||
|
||||
AWS_ACCESS_KEY_ID = 'YOUR_AWS_ACCESS_KEY' # Use env vars in production
|
||||
AWS_SECRET_ACCESS_KEY = 'YOUR_AWS_SECRET_KEY' # Use env vars in production
|
||||
AWS_STORAGE_BUCKET_NAME = 'smoothschedule-media'
|
||||
AWS_S3_REGION_NAME = 'us-east-1'
|
||||
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
|
||||
|
||||
# Route53 Configuration (for custom domains)
|
||||
AWS_ROUTE53_ENABLED = True
|
||||
AWS_ROUTE53_HOSTED_ZONE_ID = 'YOUR_ZONE_ID' # Main smoothschedule.com zone
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = 'staticfiles/'
|
||||
|
||||
# Media files (User uploads)
|
||||
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
|
||||
# =============================================================================
|
||||
# CELERY CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
|
||||
# =============================================================================
|
||||
# CORS CONFIGURATION (for React frontend)
|
||||
# =============================================================================
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # React dev server
|
||||
"http://localhost:8000",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# IMPORTANT: Update these for production
|
||||
SECRET_KEY = 'CHANGE_ME_IN_PRODUCTION' # Use env var
|
||||
DEBUG = True # Set to False in production
|
||||
ALLOWED_HOSTS = ['*'] # Restrict in production
|
||||
|
||||
# Session security
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = False # Set True in production (HTTPS)
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# CSRF security
|
||||
CSRF_COOKIE_HTTPONLY = False # Must be False for DRF
|
||||
CSRF_COOKIE_SECURE = False # Set True in production (HTTPS)
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 10}},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# CODE REVIEW CHECKLIST VERIFICATION
|
||||
# =============================================================================
|
||||
|
||||
"""
|
||||
✓ 1. Middleware Order: MasqueradeAuditMiddleware comes AFTER HijackUserMiddleware
|
||||
✓ 2. Tenant Model: Uses django_tenants.models.TenantMixin (see core/models.py)
|
||||
✓ 3. De-bloat Script: Removes templates/ folder (see setup_project.sh)
|
||||
|
||||
Additional Security Notes:
|
||||
- DATABASE_ROUTERS configured for schema isolation
|
||||
- HIJACK_AUTHORIZATION_CHECK points to our custom permission matrix
|
||||
- Structured logging configured for audit trail
|
||||
- AWS S3 configured for file storage
|
||||
- CORS configured for React frontend
|
||||
"""
|
||||
2
core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Core app initialization
|
||||
default_app_config = 'core.apps.CoreConfig'
|
||||
237
core/admin.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Smooth Schedule Core App Admin Configuration
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django_tenants.admin import TenantAdminMixin
|
||||
from .models import Tenant, Domain, PermissionGrant
|
||||
|
||||
|
||||
@admin.register(Tenant)
|
||||
class TenantAdmin(TenantAdminMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Tenant management.
|
||||
"""
|
||||
list_display = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'subscription_tier',
|
||||
'is_active',
|
||||
'created_on',
|
||||
'user_count',
|
||||
'domain_list',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_active',
|
||||
'subscription_tier',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'contact_email',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'schema_name',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'schema_name', 'created_on')
|
||||
}),
|
||||
('Subscription', {
|
||||
'fields': ('subscription_tier', 'is_active', 'max_users', 'max_resources')
|
||||
}),
|
||||
('Contact', {
|
||||
'fields': ('contact_email', 'phone')
|
||||
}),
|
||||
)
|
||||
|
||||
def user_count(self, obj):
|
||||
"""Display count of users in this tenant"""
|
||||
count = obj.users.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
'green' if count < obj.max_users else 'red',
|
||||
count
|
||||
)
|
||||
user_count.short_description = 'Users'
|
||||
|
||||
def domain_list(self, obj):
|
||||
"""Display list of domains for this tenant"""
|
||||
domains = obj.domain_set.all()
|
||||
if not domains:
|
||||
return '-'
|
||||
|
||||
domain_links = []
|
||||
for domain in domains:
|
||||
url = reverse('admin:core_domain_change', args=[domain.pk])
|
||||
domain_links.append(f'<a href="{url}">{domain.domain}</a>')
|
||||
|
||||
return format_html(' | '.join(domain_links))
|
||||
domain_list.short_description = 'Domains'
|
||||
|
||||
|
||||
@admin.register(Domain)
|
||||
class DomainAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Domain management.
|
||||
"""
|
||||
list_display = [
|
||||
'domain',
|
||||
'tenant',
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
'verified_status',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'domain',
|
||||
'tenant__name',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'verified_at',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Domain Information', {
|
||||
'fields': ('domain', 'tenant', 'is_primary')
|
||||
}),
|
||||
('Custom Domain Settings', {
|
||||
'fields': (
|
||||
'is_custom_domain',
|
||||
'route53_zone_id',
|
||||
'route53_record_set_id',
|
||||
'ssl_certificate_arn',
|
||||
'verified_at',
|
||||
),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def verified_status(self, obj):
|
||||
"""Display verification status with color coding"""
|
||||
if obj.is_verified():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Verified</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: orange;">⚠ Pending</span>'
|
||||
)
|
||||
verified_status.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(PermissionGrant)
|
||||
class PermissionGrantAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Permission Grant management.
|
||||
"""
|
||||
list_display = [
|
||||
'id',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
'status',
|
||||
'time_left',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'grantor__email',
|
||||
'grantee__email',
|
||||
'action',
|
||||
'reason',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'granted_at',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Grant Information', {
|
||||
'fields': ('grantor', 'grantee', 'action', 'reason')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('granted_at', 'expires_at', 'revoked_at')
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('ip_address', 'user_agent'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def status(self, obj):
|
||||
"""Display status with color coding"""
|
||||
if obj.revoked_at:
|
||||
return format_html(
|
||||
'<span style="color: red;">✗ Revoked</span>'
|
||||
)
|
||||
elif obj.is_active():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Active</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: gray;">⊘ Expired</span>'
|
||||
)
|
||||
status.short_description = 'Status'
|
||||
|
||||
def time_left(self, obj):
|
||||
"""Display remaining time"""
|
||||
remaining = obj.time_remaining()
|
||||
if remaining is None:
|
||||
return '-'
|
||||
|
||||
minutes = int(remaining.total_seconds() / 60)
|
||||
if minutes < 5:
|
||||
color = 'red'
|
||||
elif minutes < 15:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green'
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {};">{} min</span>',
|
||||
color,
|
||||
minutes
|
||||
)
|
||||
time_left.short_description = 'Time Left'
|
||||
|
||||
actions = ['revoke_grants']
|
||||
|
||||
def revoke_grants(self, request, queryset):
|
||||
"""Admin action to revoke permission grants"""
|
||||
count = 0
|
||||
for grant in queryset:
|
||||
if grant.is_active():
|
||||
grant.revoke()
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Successfully revoked {count} permission grant(s).'
|
||||
)
|
||||
revoke_grants.short_description = 'Revoke selected grants'
|
||||
18
core/apps.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Smooth Schedule Core App Configuration
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
verbose_name = 'Smooth Schedule Core'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals and perform app initialization.
|
||||
"""
|
||||
# Import signals here when needed
|
||||
# from . import signals
|
||||
pass
|
||||
241
core/middleware.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Smooth Schedule Masquerade Audit Middleware
|
||||
Captures and logs masquerading activity for compliance and security auditing
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger('smoothschedule.security.masquerade')
|
||||
|
||||
|
||||
class MasqueradeAuditMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Audit middleware that tracks masquerading (hijack) activity.
|
||||
|
||||
CRITICAL: This middleware MUST be placed AFTER HijackUserMiddleware in settings.
|
||||
|
||||
Responsibilities:
|
||||
1. Detect when a user is being masqueraded (hijacked)
|
||||
2. Extract the original admin user from session
|
||||
3. Enrich request object with audit context
|
||||
4. Log structured audit events
|
||||
|
||||
The enriched request will have:
|
||||
- request.actual_user: The original admin (if masquerading)
|
||||
- request.is_masquerading: Boolean flag
|
||||
- request.masquerade_metadata: Dict with audit info
|
||||
|
||||
Example log output:
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"action": "API_CALL",
|
||||
"endpoint": "/api/customers/",
|
||||
"method": "GET",
|
||||
"apparent_user": "customer@example.com",
|
||||
"actual_user": "support@chronoflow.com",
|
||||
"masquerading": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Process incoming request to detect and log masquerading.
|
||||
"""
|
||||
# Initialize masquerade flags
|
||||
request.is_masquerading = False
|
||||
request.actual_user = None
|
||||
request.masquerade_metadata = {}
|
||||
|
||||
# Check if user is authenticated
|
||||
if not hasattr(request, 'user') or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Check for hijack session data
|
||||
# django-hijack stores the original user ID in session['hijack_history']
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if hijack_history and len(hijack_history) > 0:
|
||||
# User is being masqueraded
|
||||
request.is_masquerading = True
|
||||
|
||||
# Extract original admin user ID from hijack history
|
||||
# hijack_history is a list of user IDs: [original_user_id, ...]
|
||||
original_user_id = hijack_history[0]
|
||||
|
||||
# Load the actual admin user
|
||||
from users.models import User
|
||||
try:
|
||||
actual_user = User.objects.get(pk=original_user_id)
|
||||
request.actual_user = actual_user
|
||||
|
||||
# Build metadata for audit logging
|
||||
request.masquerade_metadata = {
|
||||
'apparent_user_id': request.user.id,
|
||||
'apparent_user_email': request.user.email,
|
||||
'apparent_user_role': request.user.role,
|
||||
'actual_user_id': actual_user.id,
|
||||
'actual_user_email': actual_user.email,
|
||||
'actual_user_role': actual_user.role,
|
||||
'hijack_started_at': request.session.get('hijack_started_at'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
except User.DoesNotExist:
|
||||
# Original user was deleted? This shouldn't happen but log it
|
||||
logger.error(
|
||||
f"Hijack session references non-existent user ID: {original_user_id}. "
|
||||
f"Current user: {request.user.email}"
|
||||
)
|
||||
# Clear the corrupted hijack session
|
||||
request.session.pop('hijack_history', None)
|
||||
request.is_masquerading = False
|
||||
|
||||
return None
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
"""
|
||||
Log audit event when masquerading user accesses a view.
|
||||
Only logs for authenticated, non-admin endpoints.
|
||||
"""
|
||||
if not request.is_masquerading:
|
||||
return None
|
||||
|
||||
# Skip logging for admin interface (too noisy)
|
||||
if request.path.startswith('/admin/'):
|
||||
return None
|
||||
|
||||
# Skip logging for static files and media
|
||||
if request.path.startswith('/static/') or request.path.startswith('/media/'):
|
||||
return None
|
||||
|
||||
# Build structured log entry
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'MASQUERADE_VIEW_ACCESS',
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
'view_name': view_func.__name__ if view_func else 'Unknown',
|
||||
'apparent_user': request.user.email,
|
||||
'apparent_user_role': request.user.get_role_display(),
|
||||
'actual_user': request.actual_user.email if request.actual_user else 'Unknown',
|
||||
'actual_user_role': request.actual_user.get_role_display() if request.actual_user else 'Unknown',
|
||||
'ip_address': self._get_client_ip(request),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'tenant': request.user.tenant.name if request.user.tenant else 'Platform',
|
||||
}
|
||||
|
||||
# Log as structured JSON
|
||||
logger.info(
|
||||
f"Masquerade Access: {request.actual_user.email} as {request.user.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""
|
||||
Add audit headers to response when masquerading (for debugging).
|
||||
"""
|
||||
if hasattr(request, 'is_masquerading') and request.is_masquerading:
|
||||
# Add custom headers (visible in browser dev tools)
|
||||
response['X-SmoothSchedule-Masquerading'] = 'true'
|
||||
if request.actual_user:
|
||||
response['X-SmoothSchedule-Actual-User'] = request.actual_user.email
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_client_ip(request):
|
||||
"""
|
||||
Extract client IP address from request, handling proxies.
|
||||
"""
|
||||
# Check for X-Forwarded-For header (from load balancers/proxies)
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
# Take the first IP (client IP, before proxies)
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
return ip
|
||||
|
||||
|
||||
class MasqueradeEventLogger:
|
||||
"""
|
||||
Utility class for logging masquerade lifecycle events.
|
||||
Use this for logging hijack start/end events.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_start(hijacker, hijacked, request):
|
||||
"""
|
||||
Log when a hijack session starts.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_START',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'hijacked_role': hijacked.get_role_display(),
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK START: {hijacker.email} masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_end(hijacker, hijacked, request, duration_seconds=None):
|
||||
"""
|
||||
Log when a hijack session ends.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_END',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'duration_seconds': duration_seconds,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK END: {hijacker.email} stopped masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_denied(hijacker, hijacked, request, reason=''):
|
||||
"""
|
||||
Log when a hijack attempt is denied.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_DENIED',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'attempted_hijacked_id': hijacked.id,
|
||||
'attempted_hijacked_email': hijacked.email,
|
||||
'attempted_hijacked_role': hijacked.get_role_display(),
|
||||
'denial_reason': reason,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"HIJACK DENIED: {hijacker.email} attempted to masquerade as {hijacked.email} - {reason}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
271
core/models.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Smooth Schedule Core Models
|
||||
Multi-tenancy, Domain management, and Permission Grant system
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_tenants.models import TenantMixin, DomainMixin
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Tenant(TenantMixin):
|
||||
"""
|
||||
Tenant model for multi-schema architecture.
|
||||
Each tenant gets their own PostgreSQL schema for complete data isolation.
|
||||
"""
|
||||
name = models.CharField(max_length=100)
|
||||
created_on = models.DateField(auto_now_add=True)
|
||||
|
||||
# Subscription & billing
|
||||
is_active = models.BooleanField(default=True)
|
||||
subscription_tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
],
|
||||
default='FREE'
|
||||
)
|
||||
|
||||
# Feature flags
|
||||
max_users = models.IntegerField(default=5)
|
||||
max_resources = models.IntegerField(default=10)
|
||||
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
# Auto-created fields from TenantMixin:
|
||||
# - schema_name (unique, indexed)
|
||||
# - auto_create_schema
|
||||
# - auto_drop_schema
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Domain(DomainMixin):
|
||||
"""
|
||||
Domain model for tenant routing.
|
||||
Supports both subdomain (tenant.chronoflow.com) and custom domains.
|
||||
"""
|
||||
# Inherited from DomainMixin:
|
||||
# - domain (unique, primary key)
|
||||
# - tenant (ForeignKey to Tenant)
|
||||
# - is_primary (boolean)
|
||||
|
||||
# Route53 integration fields for custom domains
|
||||
is_custom_domain = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if this is a custom domain (not a subdomain of smoothschedule.com)"
|
||||
)
|
||||
|
||||
route53_zone_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS Route53 Hosted Zone ID for this custom domain"
|
||||
)
|
||||
|
||||
route53_record_set_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Route53 Record Set ID for DNS verification"
|
||||
)
|
||||
|
||||
# SSL certificate management (for future AWS ACM integration)
|
||||
ssl_certificate_arn = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS ACM Certificate ARN for HTTPS"
|
||||
)
|
||||
|
||||
verified_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the custom domain was verified via DNS"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
domain_type = "Custom" if self.is_custom_domain else "Subdomain"
|
||||
return f"{self.domain} ({domain_type})"
|
||||
|
||||
def is_verified(self):
|
||||
"""Check if custom domain is verified"""
|
||||
if not self.is_custom_domain:
|
||||
return True # Subdomains are always verified
|
||||
return self.verified_at is not None
|
||||
|
||||
|
||||
class PermissionGrant(models.Model):
|
||||
"""
|
||||
Time-limited permission grants (30-minute window).
|
||||
Used for temporary elevated access without permanently changing user permissions.
|
||||
|
||||
Example use cases:
|
||||
- Support agent needs temporary access to tenant data
|
||||
- Sales demo requiring elevated permissions
|
||||
- Cross-tenant operations during migrations
|
||||
"""
|
||||
grantor = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='granted_permissions',
|
||||
help_text="User who granted the permission"
|
||||
)
|
||||
|
||||
grantee = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='received_permissions',
|
||||
help_text="User who received the permission"
|
||||
)
|
||||
|
||||
action = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Specific action or permission being granted (e.g., 'view_billing', 'edit_settings')"
|
||||
)
|
||||
|
||||
reason = models.TextField(
|
||||
blank=True,
|
||||
help_text="Justification for granting this permission (audit trail)"
|
||||
)
|
||||
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
help_text="When this permission grant expires"
|
||||
)
|
||||
|
||||
revoked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="If manually revoked before expiration"
|
||||
)
|
||||
|
||||
# Audit metadata
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="IP address where grant was created"
|
||||
)
|
||||
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="Browser/client user agent"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-granted_at']
|
||||
indexes = [
|
||||
models.Index(fields=['grantee', 'expires_at']),
|
||||
models.Index(fields=['grantor', 'granted_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.grantor} → {self.grantee}: {self.action} (expires {self.expires_at})"
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
Check if this permission grant is currently active.
|
||||
Returns False if expired or revoked.
|
||||
"""
|
||||
now = timezone.now()
|
||||
|
||||
# Check if revoked
|
||||
if self.revoked_at is not None:
|
||||
return False
|
||||
|
||||
# Check if expired
|
||||
if now >= self.expires_at:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def revoke(self):
|
||||
"""Manually revoke this permission grant"""
|
||||
if self.revoked_at is None:
|
||||
self.revoked_at = timezone.now()
|
||||
self.save(update_fields=['revoked_at'])
|
||||
|
||||
def time_remaining(self):
|
||||
"""Get timedelta of remaining active time (or None if expired/revoked)"""
|
||||
if not self.is_active():
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
return self.expires_at - now
|
||||
|
||||
@classmethod
|
||||
def create_grant(cls, grantor, grantee, action, reason="", duration_minutes=30, ip_address=None, user_agent=""):
|
||||
"""
|
||||
Factory method to create a time-limited permission grant.
|
||||
|
||||
Args:
|
||||
grantor: User granting the permission
|
||||
grantee: User receiving the permission
|
||||
action: Permission action string
|
||||
reason: Justification for audit trail
|
||||
duration_minutes: How long the grant is valid (default 30)
|
||||
ip_address: IP address of the request
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
PermissionGrant instance
|
||||
"""
|
||||
now = timezone.now()
|
||||
expires_at = now + timedelta(minutes=duration_minutes)
|
||||
|
||||
return cls.objects.create(
|
||||
grantor=grantor,
|
||||
grantee=grantee,
|
||||
action=action,
|
||||
reason=reason,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
|
||||
class TierLimit(models.Model):
|
||||
"""
|
||||
Defines resource limits for each subscription tier.
|
||||
Used by HasQuota permission to enforce hard blocks.
|
||||
"""
|
||||
tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
]
|
||||
)
|
||||
|
||||
feature_code = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')"
|
||||
)
|
||||
|
||||
limit = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Maximum allowed count for this feature"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['tier', 'feature_code']
|
||||
ordering = ['tier', 'feature_code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
||||
|
||||
285
core/permissions.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Smooth Schedule Hijack Permissions
|
||||
Implements the masquerading "Matrix" - strict rules for who can impersonate whom
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def can_hijack(hijacker, hijacked):
|
||||
"""
|
||||
Determine if hijacker (the admin) can masquerade as hijacked (target user).
|
||||
|
||||
The Matrix:
|
||||
┌──────────────────────┬─────────────────────────────────────────────────┐
|
||||
│ Hijacker Role │ Can Hijack │
|
||||
├──────────────────────┼─────────────────────────────────────────────────┤
|
||||
│ SUPERUSER │ Anyone (full god mode) │
|
||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
||||
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
||||
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
||||
│ Others │ Nobody │
|
||||
└──────────────────────┴─────────────────────────────────────────────────┘
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate (the admin)
|
||||
hijacked: User being impersonated (the target)
|
||||
|
||||
Returns:
|
||||
bool: True if hijack is allowed, False otherwise
|
||||
|
||||
Security Notes:
|
||||
- Never allow self-hijacking
|
||||
- Never allow hijacking of superusers (except by other superusers)
|
||||
- Always validate tenant boundaries for tenant-scoped roles
|
||||
- Log all hijack attempts (success and failure) for audit
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Safety check: can't hijack yourself
|
||||
if hijacker.id == hijacked.id:
|
||||
return False
|
||||
|
||||
# Safety check: only superusers can hijack other superusers
|
||||
if hijacked.role == User.Role.SUPERUSER and hijacker.role != User.Role.SUPERUSER:
|
||||
return False
|
||||
|
||||
# Rule 1: SUPERUSER can hijack anyone
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
return True
|
||||
|
||||
# Rule 2: PLATFORM_SUPPORT can hijack tenant users
|
||||
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Rule 3: PLATFORM_SALES can only hijack temporary demo accounts
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
return False
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can only hijack staff and customers, not other owners/managers
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Default: deny
|
||||
return False
|
||||
|
||||
|
||||
def can_hijack_or_403(hijacker, hijacked):
|
||||
"""
|
||||
Same as can_hijack but raises PermissionDenied instead of returning False.
|
||||
Useful for views that want to use exception handling.
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate
|
||||
hijacked: User being impersonated
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If hijack is not allowed
|
||||
|
||||
Returns:
|
||||
bool: True if allowed (never returns False, raises instead)
|
||||
"""
|
||||
if not can_hijack(hijacker, hijacked):
|
||||
raise PermissionDenied(
|
||||
f"User {hijacker.email} ({hijacker.get_role_display()}) "
|
||||
f"is not authorized to masquerade as {hijacked.email} ({hijacked.get_role_display()})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_hijackable_users(hijacker):
|
||||
"""
|
||||
Get queryset of all users that the hijacker can masquerade as.
|
||||
Useful for building "Masquerade as..." dropdowns in the UI.
|
||||
|
||||
Args:
|
||||
hijacker: User who wants to hijack
|
||||
|
||||
Returns:
|
||||
QuerySet: Users that can be hijacked by this user
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Start with all users except self
|
||||
qs = User.objects.exclude(id=hijacker.id)
|
||||
|
||||
# Apply filters based on hijacker role
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
# Superuser can hijack anyone
|
||||
return qs
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
# Can hijack all tenant-level users
|
||||
return qs.filter(role__in=[
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
])
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SALES:
|
||||
# Only temporary demo accounts
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Only staff in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
# No one else can hijack
|
||||
return qs.none()
|
||||
|
||||
|
||||
def validate_hijack_chain(request):
|
||||
"""
|
||||
Validate that hijack chains are not too deep.
|
||||
Prevents: Admin1 -> Admin2 -> Admin3 -> User scenarios.
|
||||
|
||||
Smooth Schedule Security Policy: Maximum hijack depth is 1.
|
||||
You cannot hijack while already hijacked.
|
||||
|
||||
Args:
|
||||
request: Django request object
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If already in a hijack session
|
||||
|
||||
Returns:
|
||||
bool: True if allowed to start new hijack
|
||||
"""
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if len(hijack_history) > 0:
|
||||
raise PermissionDenied(
|
||||
"Cannot start a new masquerade session while already masquerading. "
|
||||
"Please exit your current session first."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# SaaS Quota Enforcement ("Hard Block" Logic)
|
||||
# ==============================================================================
|
||||
|
||||
def HasQuota(feature_code):
|
||||
"""
|
||||
Permission factory for SaaS tier limit enforcement.
|
||||
|
||||
Returns a DRF permission class that blocks write operations when
|
||||
tenant has exceeded their quota for a specific feature.
|
||||
|
||||
The "Hard Block": Prevents resource creation when tenant hits limit.
|
||||
|
||||
Usage:
|
||||
class ResourceViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
||||
|
||||
Args:
|
||||
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')
|
||||
|
||||
Returns:
|
||||
QuotaPermission class configured for the feature
|
||||
|
||||
How it Works:
|
||||
1. Read operations (GET/HEAD/OPTIONS) always allowed
|
||||
2. Write operations check current usage vs tier limit
|
||||
3. If usage >= limit, raises PermissionDenied (403)
|
||||
"""
|
||||
from rest_framework.permissions import BasePermission
|
||||
from django.apps import apps
|
||||
|
||||
class QuotaPermission(BasePermission):
|
||||
"""
|
||||
Dynamically generated permission class for quota checking.
|
||||
"""
|
||||
|
||||
# Map feature codes to model paths for usage counting
|
||||
# CRITICAL: This map must be populated for the permission to work
|
||||
USAGE_MAP = {
|
||||
'MAX_RESOURCES': 'schedule.Resource',
|
||||
'MAX_USERS': 'users.User',
|
||||
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if tenant has quota for this operation.
|
||||
|
||||
Returns True for read operations, checks quota for writes.
|
||||
"""
|
||||
# Allow all read-only operations (GET, HEAD, OPTIONS)
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return True
|
||||
|
||||
# Get tenant from request
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if not tenant:
|
||||
# No tenant in request - allow for public schema operations
|
||||
return True
|
||||
|
||||
# Get the model to check usage against
|
||||
model_path = self.USAGE_MAP.get(feature_code)
|
||||
if not model_path:
|
||||
# Feature not mapped - fail safe by allowing
|
||||
# (Production: you'd want to log this as a configuration error)
|
||||
return True
|
||||
|
||||
# Get the model class
|
||||
try:
|
||||
app_label, model_name = model_path.split('.')
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
except (ValueError, LookupError):
|
||||
# Invalid model path - fail safe
|
||||
return True
|
||||
|
||||
# Get the tier limit for this tenant
|
||||
try:
|
||||
from core.models import TierLimit
|
||||
limit = TierLimit.objects.get(
|
||||
tier=tenant.subscription_tier,
|
||||
feature_code=feature_code
|
||||
).limit
|
||||
except TierLimit.DoesNotExist:
|
||||
# No limit defined - allow (unlimited)
|
||||
return True
|
||||
|
||||
# Count current usage
|
||||
# NOTE: django-tenants automatically scopes this query to tenant schema
|
||||
current_count = Model.objects.count()
|
||||
|
||||
# The "Hard Block": Enforce the limit
|
||||
if current_count >= limit:
|
||||
# Quota exceeded - deny the operation
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied(
|
||||
f"Quota exceeded: You have reached your plan limit of {limit} "
|
||||
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
|
||||
f"Please upgrade your subscription to add more."
|
||||
)
|
||||
|
||||
# Quota available - allow the operation
|
||||
return True
|
||||
|
||||
return QuotaPermission
|
||||
BIN
frontend.tar.xz
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_DEV_MODE=true
|
||||
VITE_API_URL=http://lvh.me:8000
|
||||
10
frontend/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Environment Variables Template
|
||||
# Copy this file to .env.development or .env.production and update the values
|
||||
|
||||
# API Base URL - Backend server URL
|
||||
# Development: http://lvh.me:8000
|
||||
# Production: https://api.yourdomain.com
|
||||
VITE_API_URL=http://lvh.me:8000
|
||||
|
||||
# Development mode flag (optional)
|
||||
VITE_DEV_MODE=true
|
||||
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# Production environment variables
|
||||
# Set VITE_API_URL to your production API URL
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
159
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# SmoothSchedule Frontend Development Guide
|
||||
|
||||
## Local Development Domain Setup
|
||||
|
||||
### Why lvh.me instead of localhost?
|
||||
|
||||
This project uses **lvh.me** for local development instead of `localhost` due to cookie domain restrictions in RFC 6265.
|
||||
|
||||
**The Problem with localhost:**
|
||||
- Browsers reject cookies with `domain=.localhost` for security reasons
|
||||
- `localhost` is treated as a special-use domain where domain cookies don't work
|
||||
- Cannot share cookies across subdomains like `platform.localhost` and `business.localhost`
|
||||
|
||||
**The Solution - lvh.me:**
|
||||
- `lvh.me` is a public DNS service that resolves all `*.lvh.me` domains to `127.0.0.1`
|
||||
- Browsers allow setting cookies with `domain=.lvh.me`
|
||||
- Cookies are accessible across all subdomains (platform.lvh.me, business1.lvh.me, etc.)
|
||||
- No /etc/hosts configuration needed - it just works!
|
||||
|
||||
### Development URLs
|
||||
|
||||
Use these URLs for local development:
|
||||
|
||||
- **Base domain:** `http://lvh.me:5173`
|
||||
- **Platform dashboard:** `http://platform.lvh.me:5173`
|
||||
- **Business subdomains:** `http://{subdomain}.lvh.me:5173`
|
||||
|
||||
### Multi-Tenant Architecture
|
||||
|
||||
The application uses subdomain-based multi-tenancy:
|
||||
|
||||
1. **Platform Users** (superuser, platform_manager, platform_support)
|
||||
- Access the app at `http://platform.lvh.me:5173`
|
||||
- See platform dashboard and administrative features
|
||||
|
||||
2. **Business Users** (owner, manager, staff, resource)
|
||||
- Access the app at `http://{business_subdomain}.lvh.me:5173`
|
||||
- See business-specific dashboard and features
|
||||
|
||||
### Cookie-Based Authentication
|
||||
|
||||
Tokens are stored in cookies with `domain=.lvh.me` to enable cross-subdomain access:
|
||||
|
||||
```typescript
|
||||
// Set cookie accessible across all *.lvh.me subdomains
|
||||
setCookie('access_token', token, 7); // domain=.lvh.me
|
||||
|
||||
// Cookie is accessible on:
|
||||
// - platform.lvh.me:5173
|
||||
// - business1.lvh.me:5173
|
||||
// - business2.lvh.me:5173
|
||||
// etc.
|
||||
```
|
||||
|
||||
**Key Files:**
|
||||
- `/src/utils/cookies.ts` - Cookie utilities with cross-subdomain support
|
||||
- `/src/hooks/useAuth.ts` - Authentication hooks using cookies
|
||||
- `/src/api/client.ts` - API client with cookie-based auth
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. User navigates to `http://platform.lvh.me:5173`
|
||||
2. If not authenticated, shows login page
|
||||
3. User enters credentials and submits
|
||||
4. Backend validates and returns JWT tokens + user data
|
||||
5. Tokens stored in cookies with `domain=.lvh.me`
|
||||
6. User data stored in React Query cache
|
||||
7. App checks user role:
|
||||
- Platform users: Stay on platform.lvh.me
|
||||
- Business users: Redirect to {business_subdomain}.lvh.me
|
||||
8. Cookies accessible on target subdomain - user sees dashboard
|
||||
|
||||
### Testing with Playwright
|
||||
|
||||
Tests use lvh.me for proper subdomain testing:
|
||||
|
||||
```typescript
|
||||
// Start on platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
|
||||
// Login sets cookies with domain=.lvh.me
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Cookies accessible, dashboard loads
|
||||
await expect(page.getByRole('heading', { name: /platform dashboard/i })).toBeVisible();
|
||||
```
|
||||
|
||||
### Running the Development Server
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Access at http://platform.lvh.me:5173
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run E2E tests
|
||||
npx playwright test
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test login-flow.spec.ts
|
||||
|
||||
# Run with UI
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
In production, replace `lvh.me` with your actual domain:
|
||||
|
||||
1. Update `src/utils/cookies.ts`:
|
||||
```typescript
|
||||
const domain = window.location.hostname.includes('yourdomain.com')
|
||||
? '.yourdomain.com'
|
||||
: window.location.hostname;
|
||||
```
|
||||
|
||||
2. Configure DNS:
|
||||
- `platform.yourdomain.com` → Your server IP
|
||||
- `*.yourdomain.com` → Your server IP (wildcard for business subdomains)
|
||||
|
||||
3. SSL certificates:
|
||||
- Get wildcard certificate for `*.yourdomain.com`
|
||||
- Or use Let's Encrypt with wildcard support
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookies not working?
|
||||
- Make sure you're using `lvh.me`, not `localhost`
|
||||
- Check browser DevTools → Application → Cookies
|
||||
- Verify `domain=.lvh.me` is set on cookies
|
||||
- Clear cookies and try again
|
||||
|
||||
### Redirect issues?
|
||||
- Check `/src/pages/LoginPage.tsx` redirect logic
|
||||
- Verify user role and business_subdomain in response
|
||||
- Check browser console for navigation errors
|
||||
|
||||
### Can't access lvh.me?
|
||||
- Verify internet connection (lvh.me requires DNS lookup)
|
||||
- Try `ping lvh.me` - should resolve to `127.0.0.1`
|
||||
- Alternative: Use `127.0.0.1.nip.io` (similar service)
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 6265 - HTTP State Management Mechanism](https://datatracker.ietf.org/doc/html/rfc6265)
|
||||
- [lvh.me DNS service](http://lvh.me)
|
||||
- [Cookie Domain Attribute Rules](https://stackoverflow.com/questions/1062963/how-do-browser-cookie-domains-work)
|
||||
29
frontend/CSP-PRODUCTION.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Content Security Policy for Production
|
||||
|
||||
During development, CSP is disabled in `index.html` to avoid conflicts with browser extensions.
|
||||
|
||||
For production, configure CSP via server headers (nginx/CloudFlare):
|
||||
|
||||
```nginx
|
||||
# nginx configuration
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://connect-js.stripe.com blob:;
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
img-src 'self' data: https:;
|
||||
connect-src 'self' https://api.stripe.com https://connect-js.stripe.com https://yourdomain.com;
|
||||
frame-src 'self' https://js.stripe.com https://connect-js.stripe.com;
|
||||
" always;
|
||||
```
|
||||
|
||||
## Why not in HTML meta tag?
|
||||
|
||||
1. **Browser extensions interfere**: Extensions inject their own CSP rules causing false errors
|
||||
2. **Dynamic configuration**: Production domains differ from development (lvh.me vs yourdomain.com)
|
||||
3. **Better control**: Server headers can vary by environment without changing source code
|
||||
4. **Standard practice**: Industry best practice is CSP via headers, not meta tags
|
||||
|
||||
## Testing CSP
|
||||
|
||||
Test your production CSP at: https://csp-evaluator.withgoogle.com/
|
||||
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --network-timeout=600000 --retry 3
|
||||
|
||||
COPY . .
|
||||
|
||||
# Startup script that ensures node_modules are in sync with package.json
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
42
frontend/Dockerfile.prod
Normal file
@@ -0,0 +1,42 @@
|
||||
# Production Dockerfile for SmoothSchedule Frontend
|
||||
# Multi-stage build: Build the app, then serve with nginx
|
||||
|
||||
# Build stage
|
||||
FROM node:22-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production=false --network-timeout=600000
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
423
frontend/README_INTEGRATION.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Frontend Integration - Step by Step
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ **API Infrastructure Complete**
|
||||
- API client with axios interceptors
|
||||
- Authentication hooks
|
||||
- All feature hooks (customers, services, resources, appointments, business)
|
||||
- Playwright testing setup
|
||||
- Login page component
|
||||
- Example App.tsx with real auth
|
||||
|
||||
✅ **Components Extracted**
|
||||
- All components from zip copied to `src/*-extracted/` directories
|
||||
- TypeScript types copied to `src/types.ts`
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### 2. Start Backend
|
||||
```bash
|
||||
# In another terminal
|
||||
cd backend
|
||||
docker-compose up
|
||||
docker-compose exec backend python manage.py migrate
|
||||
docker-compose exec backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 3. Start Frontend
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Test Login
|
||||
- Go to `http://lvh.me:5173`
|
||||
- Login with your superuser credentials
|
||||
- You should see a welcome screen with your user info!
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### Step 1: Replace App.tsx
|
||||
|
||||
```bash
|
||||
# Backup current App.tsx
|
||||
mv src/App.tsx src/App.tsx.backup
|
||||
|
||||
# Use the integrated version
|
||||
mv src/App-integrated.tsx src/App.tsx
|
||||
```
|
||||
|
||||
### Step 2: Copy Components
|
||||
|
||||
```bash
|
||||
# Copy all components at once
|
||||
cp -r src/components-extracted/* src/components/ 2>/dev/null || true
|
||||
cp -r src/layouts-extracted/* src/layouts/ 2>/dev/null || true
|
||||
cp -r src/pages-extracted/* src/pages/ 2>/dev/null || true
|
||||
|
||||
# Or copy selectively, one feature at a time (recommended)
|
||||
```
|
||||
|
||||
### Step 3: Update Component Imports
|
||||
|
||||
For each component you copy, replace mock data with API hooks.
|
||||
|
||||
#### Example: Dashboard.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { APPOINTMENTS, SERVICES } from '../mockData';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [appointments] = useState(APPOINTMENTS);
|
||||
const [services] = useState(SERVICES);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: appointments, isLoading: apptLoading } = useAppointments();
|
||||
const { data: services, isLoading: servicesLoading } = useServices();
|
||||
|
||||
if (apptLoading || servicesLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Customers.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { CUSTOMERS } from '../mockData';
|
||||
|
||||
const Customers = () => {
|
||||
const [customers] = useState(CUSTOMERS);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
|
||||
const Customers = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { data: customers, isLoading } = useCustomers({ search: searchTerm });
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Scheduler.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { APPOINTMENTS, RESOURCES, SERVICES } from '../mockData';
|
||||
|
||||
const Scheduler = () => {
|
||||
const [appointments] = useState(APPOINTMENTS);
|
||||
const [resources] = useState(RESOURCES);
|
||||
const [services] = useState(SERVICES);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
|
||||
const Scheduler = () => {
|
||||
const { data: appointments, isLoading: apptLoading } = useAppointments();
|
||||
const { data: resources, isLoading: resLoading } = useResources();
|
||||
const { data: services, isLoading: servicesLoading } = useServices();
|
||||
|
||||
if (apptLoading || resLoading || servicesLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle Create/Update/Delete Operations
|
||||
|
||||
For mutations, use the mutation hooks:
|
||||
|
||||
```typescript
|
||||
import { useCreateCustomer, useUpdateCustomer, useDeleteCustomer } from '../hooks/useCustomers';
|
||||
|
||||
const Customers = () => {
|
||||
const { data: customers } = useCustomers();
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const deleteMutation = useDeleteCustomer();
|
||||
|
||||
const handleCreateCustomer = (data: any) => {
|
||||
createMutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer created!');
|
||||
// Maybe close a modal or reset a form
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create customer:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCustomer = (id: string, updates: any) => {
|
||||
updateMutation.mutate({ id, updates }, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer updated!');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteCustomer = (id: string) => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer deleted!');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
// Your JSX with handlers
|
||||
<button onClick={() => handleCreateCustomer(newCustomerData)}>
|
||||
Create Customer
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: Update App.tsx with Full Routing
|
||||
|
||||
Once components are copied and updated, implement role-based routing in App.tsx:
|
||||
|
||||
```typescript
|
||||
// Import all your pages
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Scheduler from './pages/Scheduler';
|
||||
import Customers from './pages/Customers';
|
||||
// ... etc
|
||||
|
||||
const AppContent = () => {
|
||||
const { data: user } = useCurrentUser();
|
||||
const { data: business } = useCurrentBusiness();
|
||||
|
||||
if (!user) return <LoginPage />;
|
||||
|
||||
// Platform users
|
||||
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<PlatformLayout user={user} />}>
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses />} />
|
||||
{/* ... more platform routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<CustomerLayout business={business} user={user} />}>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
{/* ... more customer routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<BusinessLayout business={business} user={user} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
<Route path="/resources" element={<Resources />} />
|
||||
{/* ... more business routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Testing with Playwright
|
||||
|
||||
### Run Visual Tests
|
||||
|
||||
```bash
|
||||
# Run tests and compare with extracted frontend
|
||||
npm run test:headed
|
||||
|
||||
# Or use UI mode for debugging
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
### Create New Tests
|
||||
|
||||
Create test files in `tests/e2e/`:
|
||||
|
||||
```typescript
|
||||
// tests/e2e/customers.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Customers Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/');
|
||||
await page.getByLabel(/username/i).fill('testowner');
|
||||
await page.getByLabel(/password/i).fill('testpass123');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL(/\//);
|
||||
});
|
||||
|
||||
test('should display customers list', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
|
||||
// Wait for customers to load
|
||||
await page.waitForSelector('table tbody tr, [data-testid="customer-list"]');
|
||||
|
||||
// Take screenshot for comparison
|
||||
await expect(page).toHaveScreenshot('customers-list.png');
|
||||
});
|
||||
|
||||
test('should filter customers by status', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
|
||||
// Click status filter
|
||||
await page.getByRole('button', { name: /filter/i }).click();
|
||||
await page.getByRole('option', { name: /active/i }).click();
|
||||
|
||||
// Verify filtered results
|
||||
const rows = await page.locator('table tbody tr').count();
|
||||
expect(rows).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Types don't match
|
||||
**Symptom**: TypeScript errors about id being string vs number
|
||||
|
||||
**Solution**: The hooks already handle transformation. Make sure you're using the hooks, not calling apiClient directly.
|
||||
|
||||
### Issue: Components look different from extracted version
|
||||
**Symptom**: Visual differences in Playwright screenshots
|
||||
|
||||
**Solution**:
|
||||
1. Check Tailwind classes are identical
|
||||
2. Verify dark mode is applied correctly
|
||||
3. Compare CSS files from extracted version
|
||||
4. Use Playwright UI mode to inspect elements
|
||||
|
||||
### Issue: CORS errors in console
|
||||
**Symptom**: "Access-Control-Allow-Origin" errors
|
||||
|
||||
**Solution**:
|
||||
1. Verify backend is running
|
||||
2. Check CORS settings in `backend/config/settings/base.py`
|
||||
3. Ensure `X-Business-Subdomain` header is in CORS_ALLOW_HEADERS
|
||||
|
||||
### Issue: 404 on API calls
|
||||
**Symptom**: API calls return 404
|
||||
|
||||
**Solution**:
|
||||
1. Check you're using correct subdomain: `acme.lvh.me:5173`
|
||||
2. Verify backend middleware is processing `X-Business-Subdomain` header
|
||||
3. Check business exists in database with correct subdomain
|
||||
|
||||
### Issue: Data not loading
|
||||
**Symptom**: Components show loading forever
|
||||
|
||||
**Solution**:
|
||||
1. Check browser console for errors
|
||||
2. Verify backend migrations are applied
|
||||
3. Create test data in backend
|
||||
4. Check React Query DevTools (install `@tanstack/react-query-devtools`)
|
||||
|
||||
## Adding React Query DevTools
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
npm install @tanstack/react-query-devtools
|
||||
```
|
||||
|
||||
In App.tsx:
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
## Component Integration Checklist
|
||||
|
||||
- [ ] Login page works
|
||||
- [ ] Dashboard displays
|
||||
- [ ] Sidebar navigation works
|
||||
- [ ] Dark mode toggle works
|
||||
- [ ] Customers page loads data from API
|
||||
- [ ] Can create new customer
|
||||
- [ ] Can edit customer
|
||||
- [ ] Can delete customer
|
||||
- [ ] Services page loads data
|
||||
- [ ] Resources page loads data
|
||||
- [ ] Scheduler displays appointments
|
||||
- [ ] Can create appointment
|
||||
- [ ] Can drag-and-drop appointments
|
||||
- [ ] Settings page loads business data
|
||||
- [ ] Can update business settings
|
||||
- [ ] Visual tests pass
|
||||
- [ ] No console errors
|
||||
|
||||
## Next Steps After Basic Integration
|
||||
|
||||
1. **Error Boundaries**: Add error boundaries for better error handling
|
||||
2. **Loading States**: Improve loading UI with skeletons
|
||||
3. **Optimistic Updates**: Add optimistic updates for better UX
|
||||
4. **Form Validation**: Add form validation with react-hook-form or similar
|
||||
5. **Toast Notifications**: Add toast library for success/error messages
|
||||
6. **Accessibility**: Add ARIA labels and keyboard navigation
|
||||
7. **Performance**: Optimize re-renders with React.memo
|
||||
8. **Tests**: Add more comprehensive Playwright tests
|
||||
|
||||
## Resources
|
||||
|
||||
- **Main Guide**: `../FRONTEND_INTEGRATION_GUIDE.md`
|
||||
- **Backend API**: `../BACKEND_IMPLEMENTATION_SUMMARY.md`
|
||||
- **All Hooks**: Check `src/hooks/` directory
|
||||
- **Type Definitions**: `src/types.ts`
|
||||
- **Example Components**: `src/*-extracted/` directories
|
||||
|
||||
You're ready to integrate! Start with the Dashboard, then move to individual features one by one.
|
||||
17
frontend/capture-original.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Capture original frontend
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: 'tests/e2e/original-main.png', fullPage: true });
|
||||
|
||||
console.log('Screenshot saved to tests/e2e/original-main.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
12
frontend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure node_modules are in sync with package.json
|
||||
# This handles the case where the anonymous volume has stale dependencies
|
||||
if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then
|
||||
echo "Installing npm packages..."
|
||||
npm install --network-timeout=600000 --retry 3
|
||||
fi
|
||||
|
||||
# Execute the CMD
|
||||
exec "$@"
|
||||
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
48
frontend/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
|
||||
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Ensure full height for the app */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for the timeline */
|
||||
.timeline-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white antialiased transition-colors duration-200">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
frontend/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 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;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Handle SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
4647
frontend/package-lock.json
generated
Normal file
50
frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e27]: 🇺🇸
|
||||
- generic [ref=e28]: English
|
||||
- img [ref=e29]
|
||||
- button "Switch to dark mode" [ref=e31]:
|
||||
- img [ref=e32]
|
||||
- link "Login" [ref=e34] [cursor=pointer]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e36]:
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e47]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e48]
|
||||
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e50]:
|
||||
- link "Get Started Free" [ref=e51] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e52]
|
||||
- button "Watch Demo" [ref=e54]:
|
||||
- img [ref=e55]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: No credit card required
|
||||
- generic [ref=e64]:
|
||||
- img [ref=e65]
|
||||
- generic [ref=e68]: Get started today
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e78]: dashboard.smoothschedule.com
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]: Today
|
||||
- generic [ref=e83]: "12"
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: This Week
|
||||
- generic [ref=e86]: "48"
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e88]: Revenue
|
||||
- generic [ref=e89]: $2.4k
|
||||
- generic [ref=e90]:
|
||||
- generic [ref=e91]: Today's Schedule
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]: 9:00 AM
|
||||
- generic [ref=e97]: Sarah J. - Haircut
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]: 10:30 AM
|
||||
- generic [ref=e102]: Mike T. - Consultation
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]: 2:00 PM
|
||||
- generic [ref=e107]: Emma W. - Color
|
||||
- generic [ref=e109]:
|
||||
- img [ref=e111]
|
||||
- generic [ref=e114]:
|
||||
- generic [ref=e115]: New Booking!
|
||||
- generic [ref=e116]: Just now
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: TechCorp
|
||||
- generic [ref=e121]: Innovate
|
||||
- generic [ref=e122]: StartupX
|
||||
- generic [ref=e123]: GrowthCo
|
||||
- generic [ref=e124]: ScaleUp
|
||||
- generic [ref=e126]:
|
||||
- generic [ref=e127]:
|
||||
- heading "Everything You Need" [level=2] [ref=e128]
|
||||
- paragraph [ref=e129]: Powerful features to run your service business
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e133]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e135]
|
||||
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e139]
|
||||
- heading "Resource Management" [level=3] [ref=e144]
|
||||
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e146]:
|
||||
- img [ref=e148]
|
||||
- heading "Customer Portal" [level=3] [ref=e152]
|
||||
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- heading "Integrated Payments" [level=3] [ref=e158]
|
||||
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e166]
|
||||
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- heading "White-Label Ready" [level=3] [ref=e176]
|
||||
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e179] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e180]
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e185]
|
||||
- paragraph [ref=e186]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: "01"
|
||||
- img [ref=e193]
|
||||
- heading "Create Your Account" [level=3] [ref=e196]
|
||||
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e200]:
|
||||
- generic [ref=e201]: "02"
|
||||
- img [ref=e203]
|
||||
- heading "Add Your Services" [level=3] [ref=e206]
|
||||
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: "03"
|
||||
- img [ref=e212]
|
||||
- heading "Start Booking" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e221]:
|
||||
- generic [ref=e222]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e226]: 1M+
|
||||
- generic [ref=e227]: Appointments Scheduled
|
||||
- generic [ref=e228]:
|
||||
- img [ref=e230]
|
||||
- generic [ref=e234]: 5,000+
|
||||
- generic [ref=e235]: Businesses
|
||||
- generic [ref=e236]:
|
||||
- img [ref=e238]
|
||||
- generic [ref=e241]: 50+
|
||||
- generic [ref=e242]: Countries
|
||||
- generic [ref=e243]:
|
||||
- img [ref=e245]
|
||||
- generic [ref=e248]: 99.9%
|
||||
- generic [ref=e249]: Uptime
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e252]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
|
||||
- paragraph [ref=e254]: See what our customers have to say
|
||||
- generic [ref=e255]:
|
||||
- generic [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- img [ref=e258]
|
||||
- img [ref=e260]
|
||||
- img [ref=e262]
|
||||
- img [ref=e264]
|
||||
- img [ref=e266]
|
||||
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e269]:
|
||||
- generic [ref=e271]: S
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]: Sarah Johnson
|
||||
- generic [ref=e274]: Owner at Luxe Salon
|
||||
- generic [ref=e275]:
|
||||
- generic [ref=e276]:
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- img [ref=e285]
|
||||
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e288]:
|
||||
- generic [ref=e290]: M
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Michael Chen
|
||||
- generic [ref=e293]: CEO at FitLife Studios
|
||||
- generic [ref=e294]:
|
||||
- generic [ref=e295]:
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- img [ref=e304]
|
||||
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e307]:
|
||||
- generic [ref=e309]: E
|
||||
- generic [ref=e310]:
|
||||
- generic [ref=e311]: Emily Rodriguez
|
||||
- generic [ref=e312]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e314]:
|
||||
- generic [ref=e315]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
|
||||
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e318]:
|
||||
- generic [ref=e319]:
|
||||
- heading "Free" [level=3] [ref=e320]
|
||||
- paragraph [ref=e321]: Perfect for getting started
|
||||
- generic [ref=e322]: $0/month
|
||||
- link "Get Started" [ref=e323] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e326]
|
||||
- paragraph [ref=e327]: For growing businesses
|
||||
- generic [ref=e328]: $29/month
|
||||
- link "Get Started" [ref=e329] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e330]:
|
||||
- heading "Business" [level=3] [ref=e331]
|
||||
- paragraph [ref=e332]: For established teams
|
||||
- generic [ref=e333]: $79/month
|
||||
- link "Get Started" [ref=e334] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e336] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e337]
|
||||
- generic [ref=e343]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e344]
|
||||
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e346]:
|
||||
- link "Get Started Free" [ref=e347] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e348]
|
||||
- link "Talk to Sales" [ref=e350] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e351]: No credit card required
|
||||
- contentinfo [ref=e352]:
|
||||
- generic [ref=e353]:
|
||||
- generic [ref=e354]:
|
||||
- generic [ref=e355]:
|
||||
- link "Smooth Schedule" [ref=e356] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e357]
|
||||
- generic [ref=e363]: Smooth Schedule
|
||||
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e365]:
|
||||
- link "Twitter" [ref=e366] [cursor=pointer]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e367]
|
||||
- link "LinkedIn" [ref=e369] [cursor=pointer]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e370]
|
||||
- link "GitHub" [ref=e374] [cursor=pointer]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e375]
|
||||
- link "YouTube" [ref=e378] [cursor=pointer]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e379]
|
||||
- generic [ref=e382]:
|
||||
- heading "Product" [level=3] [ref=e383]
|
||||
- list [ref=e384]:
|
||||
- listitem [ref=e385]:
|
||||
- link "Features" [ref=e386] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e387]:
|
||||
- link "Pricing" [ref=e388] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e389]:
|
||||
- link "Get Started" [ref=e390] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e391]:
|
||||
- heading "Company" [level=3] [ref=e392]
|
||||
- list [ref=e393]:
|
||||
- listitem [ref=e394]:
|
||||
- link "About" [ref=e395] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e396]:
|
||||
- link "Contact" [ref=e397] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e398]:
|
||||
- heading "Legal" [level=3] [ref=e399]
|
||||
- list [ref=e400]:
|
||||
- listitem [ref=e401]:
|
||||
- link "Privacy Policy" [ref=e402] [cursor=pointer]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e403]:
|
||||
- link "Terms of Service" [ref=e404] [cursor=pointer]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e407]:
|
||||
- generic [ref=e408]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e409]:
|
||||
- generic [ref=e410]: 🔓
|
||||
- generic [ref=e411]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e412]
|
||||
- generic [ref=e413]:
|
||||
- button "Logging in..." [disabled] [ref=e414]:
|
||||
- generic [ref=e415]:
|
||||
- img [ref=e416]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e419]:
|
||||
- generic [ref=e420]:
|
||||
- generic [ref=e421]: Platform Manager
|
||||
- generic [ref=e422]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e423]:
|
||||
- generic [ref=e424]:
|
||||
- generic [ref=e425]: Platform Sales
|
||||
- generic [ref=e426]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e427]:
|
||||
- generic [ref=e428]:
|
||||
- generic [ref=e429]: Platform Support
|
||||
- generic [ref=e430]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e431]:
|
||||
- generic [ref=e432]:
|
||||
- generic [ref=e433]: Business Owner
|
||||
- generic [ref=e434]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e435]:
|
||||
- generic [ref=e436]:
|
||||
- generic [ref=e437]: Business Manager
|
||||
- generic [ref=e438]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e439]:
|
||||
- generic [ref=e440]:
|
||||
- generic [ref=e441]: Staff Member
|
||||
- generic [ref=e442]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e443]:
|
||||
- generic [ref=e444]:
|
||||
- generic [ref=e445]: Customer
|
||||
- generic [ref=e446]: CUSTOMER
|
||||
- generic [ref=e447]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e448]: test123
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e27]: 🇺🇸
|
||||
- generic [ref=e28]: English
|
||||
- img [ref=e29]
|
||||
- button "Switch to dark mode" [ref=e31]:
|
||||
- img [ref=e32]
|
||||
- link "Login" [ref=e34]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e35]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e36]:
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e47]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e48]
|
||||
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e50]:
|
||||
- link "Get Started Free" [ref=e51]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e52]
|
||||
- button "Watch Demo" [ref=e54]:
|
||||
- img [ref=e55]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: No credit card required
|
||||
- generic [ref=e64]:
|
||||
- img [ref=e65]
|
||||
- generic [ref=e68]: Get started today
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e78]: dashboard.smoothschedule.com
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]: Today
|
||||
- generic [ref=e83]: "12"
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: This Week
|
||||
- generic [ref=e86]: "48"
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e88]: Revenue
|
||||
- generic [ref=e89]: $2.4k
|
||||
- generic [ref=e90]:
|
||||
- generic [ref=e91]: Today's Schedule
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]: 9:00 AM
|
||||
- generic [ref=e97]: Sarah J. - Haircut
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]: 10:30 AM
|
||||
- generic [ref=e102]: Mike T. - Consultation
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]: 2:00 PM
|
||||
- generic [ref=e107]: Emma W. - Color
|
||||
- generic [ref=e109]:
|
||||
- img [ref=e111]
|
||||
- generic [ref=e114]:
|
||||
- generic [ref=e115]: New Booking!
|
||||
- generic [ref=e116]: Just now
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: TechCorp
|
||||
- generic [ref=e121]: Innovate
|
||||
- generic [ref=e122]: StartupX
|
||||
- generic [ref=e123]: GrowthCo
|
||||
- generic [ref=e124]: ScaleUp
|
||||
- generic [ref=e126]:
|
||||
- generic [ref=e127]:
|
||||
- heading "Everything You Need" [level=2] [ref=e128]
|
||||
- paragraph [ref=e129]: Powerful features to run your service business
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e133]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e135]
|
||||
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e139]
|
||||
- heading "Resource Management" [level=3] [ref=e144]
|
||||
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e146]:
|
||||
- img [ref=e148]
|
||||
- heading "Customer Portal" [level=3] [ref=e152]
|
||||
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- heading "Integrated Payments" [level=3] [ref=e158]
|
||||
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e166]
|
||||
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- heading "White-Label Ready" [level=3] [ref=e176]
|
||||
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e179]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e180]
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e185]
|
||||
- paragraph [ref=e186]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: "01"
|
||||
- img [ref=e193]
|
||||
- heading "Create Your Account" [level=3] [ref=e196]
|
||||
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e200]:
|
||||
- generic [ref=e201]: "02"
|
||||
- img [ref=e203]
|
||||
- heading "Add Your Services" [level=3] [ref=e206]
|
||||
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: "03"
|
||||
- img [ref=e212]
|
||||
- heading "Start Booking" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e221]:
|
||||
- generic [ref=e222]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e226]: 1M+
|
||||
- generic [ref=e227]: Appointments Scheduled
|
||||
- generic [ref=e228]:
|
||||
- img [ref=e230]
|
||||
- generic [ref=e234]: 5,000+
|
||||
- generic [ref=e235]: Businesses
|
||||
- generic [ref=e236]:
|
||||
- img [ref=e238]
|
||||
- generic [ref=e241]: 50+
|
||||
- generic [ref=e242]: Countries
|
||||
- generic [ref=e243]:
|
||||
- img [ref=e245]
|
||||
- generic [ref=e248]: 99.9%
|
||||
- generic [ref=e249]: Uptime
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e252]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
|
||||
- paragraph [ref=e254]: See what our customers have to say
|
||||
- generic [ref=e255]:
|
||||
- generic [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- img [ref=e258]
|
||||
- img [ref=e260]
|
||||
- img [ref=e262]
|
||||
- img [ref=e264]
|
||||
- img [ref=e266]
|
||||
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e269]:
|
||||
- generic [ref=e271]: S
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]: Sarah Johnson
|
||||
- generic [ref=e274]: Owner at Luxe Salon
|
||||
- generic [ref=e275]:
|
||||
- generic [ref=e276]:
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- img [ref=e285]
|
||||
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e288]:
|
||||
- generic [ref=e290]: M
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Michael Chen
|
||||
- generic [ref=e293]: CEO at FitLife Studios
|
||||
- generic [ref=e294]:
|
||||
- generic [ref=e295]:
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- img [ref=e304]
|
||||
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e307]:
|
||||
- generic [ref=e309]: E
|
||||
- generic [ref=e310]:
|
||||
- generic [ref=e311]: Emily Rodriguez
|
||||
- generic [ref=e312]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e314]:
|
||||
- generic [ref=e315]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
|
||||
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e318]:
|
||||
- generic [ref=e319]:
|
||||
- heading "Free" [level=3] [ref=e320]
|
||||
- paragraph [ref=e321]: Perfect for getting started
|
||||
- generic [ref=e322]: $0/month
|
||||
- link "Get Started" [ref=e323]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e326]
|
||||
- paragraph [ref=e327]: For growing businesses
|
||||
- generic [ref=e328]: $29/month
|
||||
- link "Get Started" [ref=e329]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e330]:
|
||||
- heading "Business" [level=3] [ref=e331]
|
||||
- paragraph [ref=e332]: For established teams
|
||||
- generic [ref=e333]: $79/month
|
||||
- link "Get Started" [ref=e334]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e336]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e337]
|
||||
- generic [ref=e343]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e344]
|
||||
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e346]:
|
||||
- link "Get Started Free" [ref=e347]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e348]
|
||||
- link "Talk to Sales" [ref=e350]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e351]: No credit card required
|
||||
- contentinfo [ref=e352]:
|
||||
- generic [ref=e353]:
|
||||
- generic [ref=e354]:
|
||||
- generic [ref=e355]:
|
||||
- link "Smooth Schedule" [ref=e356]:
|
||||
- /url: "#/"
|
||||
- img [ref=e357]
|
||||
- generic [ref=e363]: Smooth Schedule
|
||||
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e365]:
|
||||
- link "Twitter" [ref=e366]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e367]
|
||||
- link "LinkedIn" [ref=e369]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e370]
|
||||
- link "GitHub" [ref=e374]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e375]
|
||||
- link "YouTube" [ref=e378]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e379]
|
||||
- generic [ref=e382]:
|
||||
- heading "Product" [level=3] [ref=e383]
|
||||
- list [ref=e384]:
|
||||
- listitem [ref=e385]:
|
||||
- link "Features" [ref=e386]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e387]:
|
||||
- link "Pricing" [ref=e388]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e389]:
|
||||
- link "Get Started" [ref=e390]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e391]:
|
||||
- heading "Company" [level=3] [ref=e392]
|
||||
- list [ref=e393]:
|
||||
- listitem [ref=e394]:
|
||||
- link "About" [ref=e395]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e396]:
|
||||
- link "Contact" [ref=e397]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e398]:
|
||||
- heading "Legal" [level=3] [ref=e399]
|
||||
- list [ref=e400]:
|
||||
- listitem [ref=e401]:
|
||||
- link "Privacy Policy" [ref=e402]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e403]:
|
||||
- link "Terms of Service" [ref=e404]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e407]:
|
||||
- generic [ref=e408]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e409]:
|
||||
- generic [ref=e410]: 🔓
|
||||
- generic [ref=e411]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e412]
|
||||
- generic [ref=e413]:
|
||||
- button "Logging in..." [disabled] [ref=e414]:
|
||||
- generic [ref=e415]:
|
||||
- img [ref=e416]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e419]:
|
||||
- generic [ref=e420]:
|
||||
- generic [ref=e421]: Platform Manager
|
||||
- generic [ref=e422]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e423]:
|
||||
- generic [ref=e424]:
|
||||
- generic [ref=e425]: Platform Sales
|
||||
- generic [ref=e426]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e427]:
|
||||
- generic [ref=e428]:
|
||||
- generic [ref=e429]: Platform Support
|
||||
- generic [ref=e430]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e431]:
|
||||
- generic [ref=e432]:
|
||||
- generic [ref=e433]: Business Owner
|
||||
- generic [ref=e434]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e435]:
|
||||
- generic [ref=e436]:
|
||||
- generic [ref=e437]: Business Manager
|
||||
- generic [ref=e438]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e439]:
|
||||
- generic [ref=e440]:
|
||||
- generic [ref=e441]: Staff Member
|
||||
- generic [ref=e442]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e443]:
|
||||
- generic [ref=e444]:
|
||||
- generic [ref=e445]: Customer
|
||||
- generic [ref=e446]: CUSTOMER
|
||||
- generic [ref=e447]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e448]: test123
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e28]: 🇺🇸
|
||||
- generic [ref=e29]: English
|
||||
- img [ref=e30]
|
||||
- button "Switch to dark mode" [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- link "Login" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e36] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e37]:
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]:
|
||||
- generic [ref=e48]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e49]
|
||||
- paragraph [ref=e50]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e51]:
|
||||
- link "Get Started Free" [ref=e52] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e53]
|
||||
- button "Watch Demo" [ref=e56]:
|
||||
- img [ref=e57]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e59]:
|
||||
- generic [ref=e60]:
|
||||
- img [ref=e61]
|
||||
- generic [ref=e64]: No credit card required
|
||||
- generic [ref=e66]:
|
||||
- img [ref=e67]
|
||||
- generic [ref=e70]: Get started today
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e73]:
|
||||
- generic [ref=e80]: dashboard.smoothschedule.com
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]:
|
||||
- generic [ref=e83]:
|
||||
- generic [ref=e84]: Today
|
||||
- generic [ref=e85]: "12"
|
||||
- generic [ref=e86]:
|
||||
- generic [ref=e87]: This Week
|
||||
- generic [ref=e88]: "48"
|
||||
- generic [ref=e89]:
|
||||
- generic [ref=e90]: Revenue
|
||||
- generic [ref=e91]: $2.4k
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e93]: Today's Schedule
|
||||
- generic [ref=e94]:
|
||||
- generic [ref=e97]:
|
||||
- generic [ref=e98]: 9:00 AM
|
||||
- generic [ref=e99]: Sarah J. - Haircut
|
||||
- generic [ref=e102]:
|
||||
- generic [ref=e103]: 10:30 AM
|
||||
- generic [ref=e104]: Mike T. - Consultation
|
||||
- generic [ref=e107]:
|
||||
- generic [ref=e108]: 2:00 PM
|
||||
- generic [ref=e109]: Emma W. - Color
|
||||
- generic [ref=e111]:
|
||||
- img [ref=e113]
|
||||
- generic [ref=e116]:
|
||||
- generic [ref=e117]: New Booking!
|
||||
- generic [ref=e118]: Just now
|
||||
- generic [ref=e119]:
|
||||
- paragraph [ref=e120]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e121]:
|
||||
- generic [ref=e122]: TechCorp
|
||||
- generic [ref=e123]: Innovate
|
||||
- generic [ref=e124]: StartupX
|
||||
- generic [ref=e125]: GrowthCo
|
||||
- generic [ref=e126]: ScaleUp
|
||||
- generic [ref=e128]:
|
||||
- generic [ref=e129]:
|
||||
- heading "Everything You Need" [level=2] [ref=e130]
|
||||
- paragraph [ref=e131]: Powerful features to run your service business
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]:
|
||||
- img [ref=e135]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e140]
|
||||
- paragraph [ref=e141]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e142]:
|
||||
- img [ref=e144]
|
||||
- heading "Resource Management" [level=3] [ref=e149]
|
||||
- paragraph [ref=e150]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e151]:
|
||||
- img [ref=e153]
|
||||
- heading "Customer Portal" [level=3] [ref=e157]
|
||||
- paragraph [ref=e158]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e159]:
|
||||
- img [ref=e161]
|
||||
- heading "Integrated Payments" [level=3] [ref=e164]
|
||||
- paragraph [ref=e165]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e168]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e174]
|
||||
- paragraph [ref=e175]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e176]:
|
||||
- img [ref=e178]
|
||||
- heading "White-Label Ready" [level=3] [ref=e184]
|
||||
- paragraph [ref=e185]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e187] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e188]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e194]
|
||||
- paragraph [ref=e195]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: "01"
|
||||
- img [ref=e202]
|
||||
- heading "Create Your Account" [level=3] [ref=e207]
|
||||
- paragraph [ref=e208]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e211]:
|
||||
- generic [ref=e212]: "02"
|
||||
- img [ref=e214]
|
||||
- heading "Add Your Services" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e220]:
|
||||
- generic [ref=e221]: "03"
|
||||
- img [ref=e223]
|
||||
- heading "Start Booking" [level=3] [ref=e228]
|
||||
- paragraph [ref=e229]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e232]:
|
||||
- generic [ref=e233]:
|
||||
- img [ref=e235]
|
||||
- generic [ref=e240]: 1M+
|
||||
- generic [ref=e241]: Appointments Scheduled
|
||||
- generic [ref=e242]:
|
||||
- img [ref=e244]
|
||||
- generic [ref=e250]: 5,000+
|
||||
- generic [ref=e251]: Businesses
|
||||
- generic [ref=e252]:
|
||||
- img [ref=e254]
|
||||
- generic [ref=e258]: 50+
|
||||
- generic [ref=e259]: Countries
|
||||
- generic [ref=e260]:
|
||||
- img [ref=e262]
|
||||
- generic [ref=e265]: 99.9%
|
||||
- generic [ref=e266]: Uptime
|
||||
- generic [ref=e268]:
|
||||
- generic [ref=e269]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e270]
|
||||
- paragraph [ref=e271]: See what our customers have to say
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]:
|
||||
- generic [ref=e274]:
|
||||
- img [ref=e275]
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- blockquote [ref=e285]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e286]:
|
||||
- generic [ref=e288]: S
|
||||
- generic [ref=e289]:
|
||||
- generic [ref=e290]: Sarah Johnson
|
||||
- generic [ref=e291]: Owner at Luxe Salon
|
||||
- generic [ref=e292]:
|
||||
- generic [ref=e293]:
|
||||
- img [ref=e294]
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- blockquote [ref=e304]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e305]:
|
||||
- generic [ref=e307]: M
|
||||
- generic [ref=e308]:
|
||||
- generic [ref=e309]: Michael Chen
|
||||
- generic [ref=e310]: CEO at FitLife Studios
|
||||
- generic [ref=e311]:
|
||||
- generic [ref=e312]:
|
||||
- img [ref=e313]
|
||||
- img [ref=e315]
|
||||
- img [ref=e317]
|
||||
- img [ref=e319]
|
||||
- img [ref=e321]
|
||||
- blockquote [ref=e323]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e326]: E
|
||||
- generic [ref=e327]:
|
||||
- generic [ref=e328]: Emily Rodriguez
|
||||
- generic [ref=e329]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e331]:
|
||||
- generic [ref=e332]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e333]
|
||||
- paragraph [ref=e334]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e335]:
|
||||
- generic [ref=e336]:
|
||||
- heading "Free" [level=3] [ref=e337]
|
||||
- paragraph [ref=e338]: Perfect for getting started
|
||||
- generic [ref=e339]: $0/month
|
||||
- link "Get Started" [ref=e340] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e341]:
|
||||
- generic [ref=e342]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e343]
|
||||
- paragraph [ref=e344]: For growing businesses
|
||||
- generic [ref=e345]: $29/month
|
||||
- link "Get Started" [ref=e346] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e347]:
|
||||
- heading "Business" [level=3] [ref=e348]
|
||||
- paragraph [ref=e349]: For established teams
|
||||
- generic [ref=e350]: $79/month
|
||||
- link "Get Started" [ref=e351] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e353] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e354]
|
||||
- generic [ref=e361]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e362]
|
||||
- paragraph [ref=e363]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e364]:
|
||||
- link "Get Started Free" [ref=e365] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e366]
|
||||
- link "Talk to Sales" [ref=e369] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e370]: No credit card required
|
||||
- contentinfo [ref=e371]:
|
||||
- generic [ref=e372]:
|
||||
- generic [ref=e373]:
|
||||
- generic [ref=e374]:
|
||||
- link "Smooth Schedule" [ref=e375] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e376]
|
||||
- generic [ref=e382]: Smooth Schedule
|
||||
- paragraph [ref=e383]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e384]:
|
||||
- link "Twitter" [ref=e385] [cursor=pointer]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e386]
|
||||
- link "LinkedIn" [ref=e388] [cursor=pointer]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e389]
|
||||
- link "GitHub" [ref=e393] [cursor=pointer]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e394]
|
||||
- link "YouTube" [ref=e397] [cursor=pointer]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e398]
|
||||
- generic [ref=e401]:
|
||||
- heading "Product" [level=3] [ref=e402]
|
||||
- list [ref=e403]:
|
||||
- listitem [ref=e404]:
|
||||
- link "Features" [ref=e405] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e406]:
|
||||
- link "Pricing" [ref=e407] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e408]:
|
||||
- link "Get Started" [ref=e409] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e410]:
|
||||
- heading "Company" [level=3] [ref=e411]
|
||||
- list [ref=e412]:
|
||||
- listitem [ref=e413]:
|
||||
- link "About" [ref=e414] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e415]:
|
||||
- link "Contact" [ref=e416] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e417]:
|
||||
- heading "Legal" [level=3] [ref=e418]
|
||||
- list [ref=e419]:
|
||||
- listitem [ref=e420]:
|
||||
- link "Privacy Policy" [ref=e421] [cursor=pointer]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e422]:
|
||||
- link "Terms of Service" [ref=e423] [cursor=pointer]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e425]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e426]:
|
||||
- generic [ref=e427]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e428]:
|
||||
- generic [ref=e429]: 🔓
|
||||
- generic [ref=e430]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e431]
|
||||
- generic [ref=e432]:
|
||||
- button "Logging in..." [disabled] [ref=e433]:
|
||||
- generic [ref=e434]:
|
||||
- img [ref=e435]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e438]:
|
||||
- generic [ref=e439]:
|
||||
- generic [ref=e440]: Platform Manager
|
||||
- generic [ref=e441]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e442]:
|
||||
- generic [ref=e443]:
|
||||
- generic [ref=e444]: Platform Sales
|
||||
- generic [ref=e445]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e446]:
|
||||
- generic [ref=e447]:
|
||||
- generic [ref=e448]: Platform Support
|
||||
- generic [ref=e449]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e450]:
|
||||
- generic [ref=e451]:
|
||||
- generic [ref=e452]: Business Owner
|
||||
- generic [ref=e453]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e454]:
|
||||
- generic [ref=e455]:
|
||||
- generic [ref=e456]: Business Manager
|
||||
- generic [ref=e457]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e458]:
|
||||
- generic [ref=e459]:
|
||||
- generic [ref=e460]: Staff Member
|
||||
- generic [ref=e461]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e462]:
|
||||
- generic [ref=e463]:
|
||||
- generic [ref=e464]: Customer
|
||||
- generic [ref=e465]: CUSTOMER
|
||||
- generic [ref=e466]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e467]: test123
|
||||
```
|
||||
|
After Width: | Height: | Size: 410 KiB |
85
frontend/playwright-report/index.html
Normal file
58
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for SmoothSchedule
|
||||
* Tests the React frontend with proper subdomain support
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
/* Reporter to use */
|
||||
reporter: 'html',
|
||||
|
||||
/* Shared settings for all the projects below */
|
||||
use: {
|
||||
/* Base URL for all tests */
|
||||
baseURL: 'http://lvh.me:5174',
|
||||
|
||||
/* Collect trace when retrying the failed test */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://lvh.me:5174',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
36
frontend/src/App.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar for timeline */
|
||||
.timeline-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #4a5568;
|
||||
}
|
||||
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #68768a;
|
||||
}
|
||||
|
||||
/* Preserve existing app styles */
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
557
frontend/src/App.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Main App Component - Integrated with Real API
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||
import { setCookie } from './utils/cookies';
|
||||
import { DevQuickLogin } from './components/DevQuickLogin';
|
||||
|
||||
// Import Login Page
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
// Import layouts
|
||||
import BusinessLayout from './layouts/BusinessLayout';
|
||||
import PlatformLayout from './layouts/PlatformLayout';
|
||||
import CustomerLayout from './layouts/CustomerLayout';
|
||||
import MarketingLayout from './layouts/MarketingLayout';
|
||||
|
||||
// Import marketing pages
|
||||
import HomePage from './pages/marketing/HomePage';
|
||||
import FeaturesPage from './pages/marketing/FeaturesPage';
|
||||
import PricingPage from './pages/marketing/PricingPage';
|
||||
import AboutPage from './pages/marketing/AboutPage';
|
||||
import ContactPage from './pages/marketing/ContactPage';
|
||||
import SignupPage from './pages/marketing/SignupPage';
|
||||
|
||||
// Import pages
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Scheduler from './pages/Scheduler';
|
||||
import Customers from './pages/Customers';
|
||||
import Settings from './pages/Settings';
|
||||
import Payments from './pages/Payments';
|
||||
import Resources from './pages/Resources';
|
||||
import Services from './pages/Services';
|
||||
import Staff from './pages/Staff';
|
||||
import CustomerDashboard from './pages/customer/CustomerDashboard';
|
||||
import ResourceDashboard from './pages/resource/ResourceDashboard';
|
||||
import BookingPage from './pages/customer/BookingPage';
|
||||
import TrialExpired from './pages/TrialExpired';
|
||||
import Upgrade from './pages/Upgrade';
|
||||
|
||||
// Import platform pages
|
||||
import PlatformDashboard from './pages/platform/PlatformDashboard';
|
||||
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
|
||||
import PlatformSupport from './pages/platform/PlatformSupport';
|
||||
import PlatformUsers from './pages/platform/PlatformUsers';
|
||||
import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading Component
|
||||
*/
|
||||
const LoadingScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error Component
|
||||
*/
|
||||
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{t('common.reload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* App Content - Handles routing based on auth state
|
||||
*/
|
||||
const AppContent: React.FC = () => {
|
||||
// Check for tokens in URL FIRST - before any queries execute
|
||||
// This handles login/masquerade redirects that pass tokens in the URL
|
||||
const [processingUrlTokens] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return !!(params.get('access_token') && params.get('refresh_token'));
|
||||
});
|
||||
|
||||
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
|
||||
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const updateBusinessMutation = useUpdateBusiness();
|
||||
const masqueradeMutation = useMasquerade();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Apply dark mode class
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode);
|
||||
}, [darkMode]);
|
||||
|
||||
// Handle tokens in URL (from login or masquerade redirect)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
// Extract masquerade stack if present (for masquerade banner)
|
||||
const masqueradeStackParam = params.get('masquerade_stack');
|
||||
if (masqueradeStackParam) {
|
||||
try {
|
||||
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse masquerade stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility, also check for original_user parameter
|
||||
const originalUserParam = params.get('original_user');
|
||||
if (originalUserParam && !masqueradeStackParam) {
|
||||
try {
|
||||
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
|
||||
// Convert old format to new stack format (single entry)
|
||||
const stack = [{
|
||||
user_id: originalUser.id,
|
||||
username: originalUser.username,
|
||||
role: originalUser.role,
|
||||
business_id: originalUser.business,
|
||||
business_subdomain: originalUser.business_subdomain,
|
||||
}];
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse original user', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cookies using helper (handles domain correctly)
|
||||
setCookie('access_token', accessToken, 7);
|
||||
setCookie('refresh_token', refreshToken, 7);
|
||||
|
||||
// Clear session cookie to prevent interference with JWT
|
||||
// (Django session cookie might take precedence over JWT)
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
|
||||
// Clean URL
|
||||
const newUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Force reload to ensure auth state is picked up
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show loading while processing URL tokens (before reload happens)
|
||||
if (processingUrlTokens) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (userLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Helper to detect root domain (for marketing site)
|
||||
const isRootDomain = (): boolean => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
// Not authenticated - show public routes
|
||||
if (!user) {
|
||||
// On root domain, show marketing site
|
||||
if (isRootDomain()) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MarketingLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// On business subdomain, show login
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (userError) {
|
||||
return <ErrorScreen error={userError as Error} />;
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const toggleTheme = () => setDarkMode((prev) => !prev);
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
const handleUpdateBusiness = (updates: Partial<any>) => {
|
||||
updateBusinessMutation.mutate(updates);
|
||||
};
|
||||
|
||||
const handleMasquerade = (targetUser: any) => {
|
||||
// Call the masquerade API with the target user's username
|
||||
// Fallback to email prefix if username is not available
|
||||
const username = targetUser.username || targetUser.email?.split('@')[0];
|
||||
if (!username) {
|
||||
console.error('Cannot masquerade: no username or email available', targetUser);
|
||||
return;
|
||||
}
|
||||
masqueradeMutation.mutate(username);
|
||||
};
|
||||
|
||||
// Helper to check access based on roles
|
||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<PlatformLayout
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(user.role === 'superuser' || user.role === 'platform_manager') && (
|
||||
<>
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
|
||||
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupport />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
)}
|
||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={
|
||||
user.role === 'superuser' || user.role === 'platform_manager'
|
||||
? '/platform/dashboard'
|
||||
: '/platform/support'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<CustomerLayout
|
||||
business={business || ({} as any)}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Business loading - show loading with user info
|
||||
if (businessLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Check if we're on root/platform domain without proper business context
|
||||
const currentHostname = window.location.hostname;
|
||||
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
|
||||
|
||||
// Business error or no business found
|
||||
if (businessError || !business) {
|
||||
// If user is a business owner on root domain, redirect to their business
|
||||
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// If on root/platform and shouldn't be here, show appropriate message
|
||||
if (isRootOrPlatform) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{user.business_subdomain
|
||||
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
|
||||
: 'Your account is not associated with a business. Please contact support.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{user.business_subdomain && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Go to Business
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
||||
// Check if email verification is required
|
||||
if (!user.email_verified) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if trial has expired
|
||||
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
||||
|
||||
// Allowed routes when trial is expired
|
||||
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
|
||||
const currentPath = window.location.pathname;
|
||||
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
||||
|
||||
// If trial expired and not on allowed route, redirect to trial-expired
|
||||
if (isTrialExpired && !isOnAllowedRoute) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<BusinessLayout
|
||||
business={business}
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
updateBusiness={handleUpdateBusiness}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Trial and Upgrade Routes */}
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Messages</h1>
|
||||
<p className="text-gray-600">Messages feature coming soon...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
|
||||
/>
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main App Component
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<DevQuickLogin />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
113
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Authentication API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export interface MasqueradeStackEntry {
|
||||
user_id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
business_id?: number;
|
||||
business_subdomain?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar_url?: string;
|
||||
email_verified?: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
};
|
||||
masquerade_stack?: MasqueradeStackEntry[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar_url?: string;
|
||||
email_verified?: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login/', credentials);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/logout/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/api/auth/me/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
export const refreshToken = async (refresh: string): Promise<{ access: string }> => {
|
||||
const response = await apiClient.post('/api/auth/refresh/', { refresh });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Masquerade as another user
|
||||
*/
|
||||
export const masquerade = async (
|
||||
username: string,
|
||||
masquerade_stack?: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
`/api/users/${username}/masquerade/`,
|
||||
{ masquerade_stack }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop masquerading and return to previous user
|
||||
*/
|
||||
export const stopMasquerade = async (
|
||||
masquerade_stack: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
'/api/users/stop_masquerade/',
|
||||
{ masquerade_stack }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
106
frontend/src/api/business.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Business API - Resources and Users
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentials } from '../types';
|
||||
|
||||
/**
|
||||
* Get all resources for the current business
|
||||
*/
|
||||
export const getResources = async (): Promise<Resource[]> => {
|
||||
const response = await apiClient.get<Resource[]>('/api/resources/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users for the current business
|
||||
*/
|
||||
export const getBusinessUsers = async (): Promise<User[]> => {
|
||||
const response = await apiClient.get<User[]>('/api/business/users/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get business OAuth settings and available platform providers
|
||||
*/
|
||||
export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsResponse> => {
|
||||
const response = await apiClient.get<{
|
||||
business_settings: {
|
||||
oauth_enabled_providers: string[];
|
||||
oauth_allow_registration: boolean;
|
||||
oauth_auto_link_by_email: boolean;
|
||||
};
|
||||
available_providers: string[];
|
||||
}>('/api/business/oauth-settings/');
|
||||
|
||||
// Transform snake_case to camelCase
|
||||
return {
|
||||
businessSettings: {
|
||||
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
|
||||
allowRegistration: response.data.business_settings.oauth_allow_registration,
|
||||
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
|
||||
},
|
||||
availableProviders: response.data.available_providers || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Update business OAuth settings
|
||||
*/
|
||||
export const updateBusinessOAuthSettings = async (
|
||||
settings: Partial<BusinessOAuthSettings>
|
||||
): Promise<BusinessOAuthSettingsResponse> => {
|
||||
// Transform camelCase to snake_case for backend
|
||||
const backendData: Record<string, any> = {};
|
||||
|
||||
if (settings.enabledProviders !== undefined) {
|
||||
backendData.oauth_enabled_providers = settings.enabledProviders;
|
||||
}
|
||||
if (settings.allowRegistration !== undefined) {
|
||||
backendData.oauth_allow_registration = settings.allowRegistration;
|
||||
}
|
||||
if (settings.autoLinkByEmail !== undefined) {
|
||||
backendData.oauth_auto_link_by_email = settings.autoLinkByEmail;
|
||||
}
|
||||
|
||||
const response = await apiClient.patch<{
|
||||
business_settings: {
|
||||
oauth_enabled_providers: string[];
|
||||
oauth_allow_registration: boolean;
|
||||
oauth_auto_link_by_email: boolean;
|
||||
};
|
||||
available_providers: string[];
|
||||
}>('/api/business/oauth-settings/update/', backendData);
|
||||
|
||||
// Transform snake_case to camelCase
|
||||
return {
|
||||
businessSettings: {
|
||||
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
|
||||
allowRegistration: response.data.business_settings.oauth_allow_registration,
|
||||
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
|
||||
},
|
||||
availableProviders: response.data.available_providers || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get business OAuth credentials (custom credentials for paid tiers)
|
||||
*/
|
||||
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentials> => {
|
||||
const response = await apiClient.get<BusinessOAuthCredentials>('/api/business/oauth-credentials/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update business OAuth credentials (custom credentials for paid tiers)
|
||||
*/
|
||||
export const updateBusinessOAuthCredentials = async (
|
||||
credentials: Partial<BusinessOAuthCredentials>
|
||||
): Promise<BusinessOAuthCredentials> => {
|
||||
const response = await apiClient.patch<BusinessOAuthCredentials>(
|
||||
'/api/business/oauth-credentials/update/',
|
||||
credentials
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
86
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* API Client
|
||||
* Axios instance configured for SmoothSchedule API
|
||||
*/
|
||||
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { API_BASE_URL, getSubdomain } from './config';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
|
||||
// Create axios instance
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // For CORS with credentials
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token and business subdomain
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Add business subdomain header if on business site
|
||||
const subdomain = getSubdomain();
|
||||
if (subdomain && subdomain !== 'platform') {
|
||||
config.headers['X-Business-Subdomain'] = subdomain;
|
||||
}
|
||||
|
||||
// Add auth token if available (from cookie)
|
||||
const token = getCookie('access_token');
|
||||
if (token) {
|
||||
// Use 'Token' prefix for Django REST Framework Token Authentication
|
||||
config.headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - handle errors and token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 Unauthorized - token expired
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Try to refresh token (from cookie)
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh/`, {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
|
||||
const { access } = response.data;
|
||||
|
||||
// Import setCookie dynamically to avoid circular dependency
|
||||
const { setCookie } = await import('../utils/cookies');
|
||||
setCookie('access_token', access, 7);
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${access}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
const { deleteCookie } = await import('../utils/cookies');
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
59
frontend/src/api/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* API Configuration
|
||||
* Centralized configuration for API endpoints and settings
|
||||
*/
|
||||
|
||||
// Determine API base URL based on environment
|
||||
const getApiBaseUrl = (): string => {
|
||||
// In production, this would be set via environment variable
|
||||
if (import.meta.env.VITE_API_URL) {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// Development: use api subdomain
|
||||
return 'http://api.lvh.me:8000';
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
/**
|
||||
* Extract subdomain from current hostname
|
||||
* Returns null if on root domain or invalid subdomain
|
||||
*/
|
||||
export const getSubdomain = (): string | null => {
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
|
||||
// lvh.me without subdomain (root domain) - no business context
|
||||
if (hostname === 'lvh.me') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Has subdomain
|
||||
if (parts.length > 1) {
|
||||
const subdomain = parts[0];
|
||||
// Exclude special subdomains
|
||||
if (['www', 'api', 'platform'].includes(subdomain)) {
|
||||
return subdomain === 'platform' ? null : subdomain;
|
||||
}
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current page is platform site
|
||||
*/
|
||||
export const isPlatformSite = (): boolean => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname.startsWith('platform.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current page is business site
|
||||
*/
|
||||
export const isBusinessSite = (): boolean => {
|
||||
const subdomain = getSubdomain();
|
||||
return subdomain !== null && subdomain !== 'platform';
|
||||
};
|
||||
51
frontend/src/api/customDomains.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Custom Domains API - Manage custom domains for businesses
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import { CustomDomain } from '../types';
|
||||
|
||||
/**
|
||||
* Get all custom domains for the current business
|
||||
*/
|
||||
export const getCustomDomains = async (): Promise<CustomDomain[]> => {
|
||||
const response = await apiClient.get<CustomDomain[]>('/api/business/domains/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new custom domain
|
||||
*/
|
||||
export const addCustomDomain = async (domain: string): Promise<CustomDomain> => {
|
||||
const response = await apiClient.post<CustomDomain>('/api/business/domains/', {
|
||||
domain: domain.toLowerCase().trim(),
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a custom domain
|
||||
*/
|
||||
export const deleteCustomDomain = async (domainId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/business/domains/${domainId}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a custom domain by checking DNS
|
||||
*/
|
||||
export const verifyCustomDomain = async (domainId: number): Promise<{ verified: boolean; message: string }> => {
|
||||
const response = await apiClient.post<{ verified: boolean; message: string }>(
|
||||
`/api/business/domains/${domainId}/verify/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a custom domain as the primary domain
|
||||
*/
|
||||
export const setPrimaryDomain = async (domainId: number): Promise<CustomDomain> => {
|
||||
const response = await apiClient.post<CustomDomain>(
|
||||
`/api/business/domains/${domainId}/set-primary/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
181
frontend/src/api/domains.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Domains API - NameSilo Integration for Domain Registration
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface DomainAvailability {
|
||||
domain: string;
|
||||
available: boolean;
|
||||
price: number | null;
|
||||
premium: boolean;
|
||||
premium_price: number | null;
|
||||
}
|
||||
|
||||
export interface DomainPrice {
|
||||
tld: string;
|
||||
registration: number;
|
||||
renewal: number;
|
||||
transfer: number;
|
||||
}
|
||||
|
||||
export interface RegistrantContact {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface DomainRegisterRequest {
|
||||
domain: string;
|
||||
years: number;
|
||||
whois_privacy: boolean;
|
||||
auto_renew: boolean;
|
||||
nameservers?: string[];
|
||||
contact: RegistrantContact;
|
||||
auto_configure: boolean;
|
||||
}
|
||||
|
||||
export interface DomainRegistration {
|
||||
id: number;
|
||||
domain: string;
|
||||
status: 'pending' | 'active' | 'expired' | 'transferred' | 'failed';
|
||||
registered_at: string | null;
|
||||
expires_at: string | null;
|
||||
auto_renew: boolean;
|
||||
whois_privacy: boolean;
|
||||
purchase_price: number | null;
|
||||
renewal_price: number | null;
|
||||
nameservers: string[];
|
||||
days_until_expiry: number | null;
|
||||
is_expiring_soon: boolean;
|
||||
created_at: string;
|
||||
// Detail fields
|
||||
registrant_first_name?: string;
|
||||
registrant_last_name?: string;
|
||||
registrant_email?: string;
|
||||
}
|
||||
|
||||
export interface DomainSearchHistory {
|
||||
id: number;
|
||||
searched_domain: string;
|
||||
was_available: boolean;
|
||||
price: number | null;
|
||||
searched_at: string;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Search for domain availability
|
||||
*/
|
||||
export const searchDomains = async (
|
||||
query: string,
|
||||
tlds: string[] = ['.com', '.net', '.org']
|
||||
): Promise<DomainAvailability[]> => {
|
||||
const response = await apiClient.post<DomainAvailability[]>('/api/domains/search/search/', {
|
||||
query,
|
||||
tlds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get TLD pricing
|
||||
*/
|
||||
export const getDomainPrices = async (): Promise<DomainPrice[]> => {
|
||||
const response = await apiClient.get<DomainPrice[]>('/api/domains/search/prices/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new domain
|
||||
*/
|
||||
export const registerDomain = async (
|
||||
data: DomainRegisterRequest
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>('/api/domains/search/register/', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all registered domains for current business
|
||||
*/
|
||||
export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
|
||||
const response = await apiClient.get<DomainRegistration[]>('/api/domains/registrations/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single domain registration
|
||||
*/
|
||||
export const getDomainRegistration = async (id: number): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.get<DomainRegistration>(`/api/domains/registrations/${id}/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update nameservers for a domain
|
||||
*/
|
||||
export const updateNameservers = async (
|
||||
id: number,
|
||||
nameservers: string[]
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/update_nameservers/`,
|
||||
{ nameservers }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle auto-renewal for a domain
|
||||
*/
|
||||
export const toggleAutoRenew = async (
|
||||
id: number,
|
||||
autoRenew: boolean
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/toggle_auto_renew/`,
|
||||
{ auto_renew: autoRenew }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renew a domain
|
||||
*/
|
||||
export const renewDomain = async (
|
||||
id: number,
|
||||
years: number = 1
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/renew/`,
|
||||
{ years }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync domain info from NameSilo
|
||||
*/
|
||||
export const syncDomain = async (id: number): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/sync/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get domain search history
|
||||
*/
|
||||
export const getSearchHistory = async (): Promise<DomainSearchHistory[]> => {
|
||||
const response = await apiClient.get<DomainSearchHistory[]>('/api/domains/history/');
|
||||
return response.data;
|
||||
};
|
||||
93
frontend/src/api/oauth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* OAuth API
|
||||
* Handles OAuth authentication flows with various providers
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface OAuthProvider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizationResponse {
|
||||
authorization_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar_url?: string;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OAuthConnection {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_user_id: string;
|
||||
email?: string;
|
||||
connected_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of enabled OAuth providers
|
||||
*/
|
||||
export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
|
||||
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/api/auth/oauth/providers/');
|
||||
return response.data.providers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - get authorization URL
|
||||
*/
|
||||
export const initiateOAuth = async (provider: string): Promise<OAuthAuthorizationResponse> => {
|
||||
const response = await apiClient.get<OAuthAuthorizationResponse>(
|
||||
`/api/auth/oauth/${provider}/authorize/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for tokens
|
||||
*/
|
||||
export const handleOAuthCallback = async (
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<OAuthTokenResponse> => {
|
||||
const response = await apiClient.post<OAuthTokenResponse>(
|
||||
`/api/auth/oauth/${provider}/callback/`,
|
||||
{
|
||||
code,
|
||||
state,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's connected OAuth accounts
|
||||
*/
|
||||
export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
|
||||
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/api/auth/oauth/connections/');
|
||||
return response.data.connections;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect an OAuth account
|
||||
*/
|
||||
export const disconnectOAuth = async (provider: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/oauth/connections/${provider}/`);
|
||||
};
|
||||
433
frontend/src/api/payments.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Payments API
|
||||
* Functions for managing payment configuration (API keys and Connect)
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type PaymentMode = 'direct_api' | 'connect' | 'none';
|
||||
export type KeyStatus = 'active' | 'invalid' | 'deprecated';
|
||||
export type AccountStatus = 'pending' | 'onboarding' | 'active' | 'restricted' | 'rejected';
|
||||
|
||||
export interface ApiKeysInfo {
|
||||
id: number;
|
||||
status: KeyStatus;
|
||||
secret_key_masked: string;
|
||||
publishable_key_masked: string;
|
||||
last_validated_at: string | null;
|
||||
stripe_account_id: string;
|
||||
stripe_account_name: string;
|
||||
validation_error: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConnectAccountInfo {
|
||||
id: number;
|
||||
business: number;
|
||||
business_name: string;
|
||||
business_subdomain: string;
|
||||
stripe_account_id: string;
|
||||
account_type: 'standard' | 'express' | 'custom';
|
||||
status: AccountStatus;
|
||||
charges_enabled: boolean;
|
||||
payouts_enabled: boolean;
|
||||
details_submitted: boolean;
|
||||
onboarding_complete: boolean;
|
||||
onboarding_link: string | null;
|
||||
onboarding_link_expires_at: string | null;
|
||||
is_onboarding_link_valid: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaymentConfig {
|
||||
payment_mode: PaymentMode;
|
||||
tier: string;
|
||||
can_accept_payments: boolean;
|
||||
api_keys: ApiKeysInfo | null;
|
||||
connect_account: ConnectAccountInfo | null;
|
||||
}
|
||||
|
||||
export interface ApiKeysValidationResult {
|
||||
valid: boolean;
|
||||
account_id?: string;
|
||||
account_name?: string;
|
||||
environment?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeysCurrentResponse {
|
||||
configured: boolean;
|
||||
id?: number;
|
||||
status?: KeyStatus;
|
||||
secret_key_masked?: string;
|
||||
publishable_key_masked?: string;
|
||||
last_validated_at?: string | null;
|
||||
stripe_account_id?: string;
|
||||
stripe_account_name?: string;
|
||||
validation_error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ConnectOnboardingResponse {
|
||||
account_type: 'standard' | 'custom';
|
||||
url: string;
|
||||
stripe_account_id?: string;
|
||||
}
|
||||
|
||||
export interface AccountSessionResponse {
|
||||
client_secret: string;
|
||||
stripe_account_id: string;
|
||||
publishable_key: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unified Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get unified payment configuration status.
|
||||
* Returns the complete payment setup for the business.
|
||||
*/
|
||||
export const getPaymentConfig = () =>
|
||||
apiClient.get<PaymentConfig>('/api/payments/config/status/');
|
||||
|
||||
// ============================================================================
|
||||
// API Keys (Free Tier)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current API key configuration (masked keys).
|
||||
*/
|
||||
export const getApiKeys = () =>
|
||||
apiClient.get<ApiKeysCurrentResponse>('/api/payments/api-keys/');
|
||||
|
||||
/**
|
||||
* Save API keys.
|
||||
* Validates and stores the provided Stripe API keys.
|
||||
*/
|
||||
export const saveApiKeys = (secretKey: string, publishableKey: string) =>
|
||||
apiClient.post<ApiKeysInfo>('/api/payments/api-keys/', {
|
||||
secret_key: secretKey,
|
||||
publishable_key: publishableKey,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate API keys without saving.
|
||||
* Tests the keys against Stripe API.
|
||||
*/
|
||||
export const validateApiKeys = (secretKey: string, publishableKey: string) =>
|
||||
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/validate/', {
|
||||
secret_key: secretKey,
|
||||
publishable_key: publishableKey,
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-validate stored API keys.
|
||||
* Tests stored keys and updates their status.
|
||||
*/
|
||||
export const revalidateApiKeys = () =>
|
||||
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/revalidate/');
|
||||
|
||||
/**
|
||||
* Delete stored API keys.
|
||||
*/
|
||||
export const deleteApiKeys = () =>
|
||||
apiClient.delete<{ success: boolean; message: string }>('/api/payments/api-keys/delete/');
|
||||
|
||||
// ============================================================================
|
||||
// Stripe Connect (Paid Tiers)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current Connect account status.
|
||||
*/
|
||||
export const getConnectStatus = () =>
|
||||
apiClient.get<ConnectAccountInfo>('/api/payments/connect/status/');
|
||||
|
||||
/**
|
||||
* Initiate Connect account onboarding.
|
||||
* Returns a URL to redirect the user for Stripe onboarding.
|
||||
*/
|
||||
export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string) =>
|
||||
apiClient.post<ConnectOnboardingResponse>('/api/payments/connect/onboard/', {
|
||||
refresh_url: refreshUrl,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh Connect onboarding link.
|
||||
* For custom Connect accounts that need a new onboarding link.
|
||||
*/
|
||||
export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: string) =>
|
||||
apiClient.post<{ url: string }>('/api/payments/connect/refresh-link/', {
|
||||
refresh_url: refreshUrl,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an Account Session for embedded Connect onboarding.
|
||||
* Returns a client_secret for initializing Stripe's embedded Connect components.
|
||||
*/
|
||||
export const createAccountSession = () =>
|
||||
apiClient.post<AccountSessionResponse>('/api/payments/connect/account-session/');
|
||||
|
||||
/**
|
||||
* Refresh Connect account status from Stripe.
|
||||
* Syncs the local account record with the current state in Stripe.
|
||||
*/
|
||||
export const refreshConnectStatus = () =>
|
||||
apiClient.post<ConnectAccountInfo>('/api/payments/connect/refresh-status/');
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Analytics
|
||||
// ============================================================================
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
business: number;
|
||||
business_name: string;
|
||||
stripe_payment_intent_id: string;
|
||||
stripe_charge_id: string;
|
||||
transaction_type: 'payment' | 'refund' | 'application_fee';
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'refunded' | 'partially_refunded';
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
application_fee_amount: number;
|
||||
fee_display: string;
|
||||
net_amount: number;
|
||||
currency: string;
|
||||
customer_email: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
stripe_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TransactionListResponse {
|
||||
results: Transaction[];
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface TransactionSummary {
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
total_volume_display: string;
|
||||
total_fees: number;
|
||||
total_fees_display: string;
|
||||
net_revenue: number;
|
||||
net_revenue_display: string;
|
||||
successful_transactions: number;
|
||||
failed_transactions: number;
|
||||
refunded_transactions: number;
|
||||
average_transaction: number;
|
||||
average_transaction_display: string;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: 'all' | 'succeeded' | 'pending' | 'failed' | 'refunded';
|
||||
transaction_type?: 'all' | 'payment' | 'refund' | 'application_fee';
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface StripeCharge {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
amount_refunded: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
paid: boolean;
|
||||
refunded: boolean;
|
||||
description: string | null;
|
||||
receipt_email: string | null;
|
||||
receipt_url: string | null;
|
||||
created: number;
|
||||
payment_method_details: Record<string, unknown> | null;
|
||||
billing_details: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ChargesResponse {
|
||||
charges: StripeCharge[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface StripePayout {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
arrival_date: number | null;
|
||||
created: number;
|
||||
description: string | null;
|
||||
destination: string | null;
|
||||
failure_message: string | null;
|
||||
method: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PayoutsResponse {
|
||||
payouts: StripePayout[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface BalanceItem {
|
||||
amount: number;
|
||||
currency: string;
|
||||
amount_display: string;
|
||||
}
|
||||
|
||||
export interface BalanceResponse {
|
||||
available: BalanceItem[];
|
||||
pending: BalanceItem[];
|
||||
available_total: number;
|
||||
pending_total: number;
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format: 'csv' | 'xlsx' | 'pdf' | 'quickbooks';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
include_details?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of transactions with optional filtering.
|
||||
*/
|
||||
export const getTransactions = (filters?: TransactionFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.status && filters.status !== 'all') params.append('status', filters.status);
|
||||
if (filters?.transaction_type && filters.transaction_type !== 'all') {
|
||||
params.append('transaction_type', filters.transaction_type);
|
||||
}
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.page_size) params.append('page_size', String(filters.page_size));
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<TransactionListResponse>(
|
||||
`/api/payments/transactions/${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single transaction by ID.
|
||||
*/
|
||||
export const getTransaction = (id: number) =>
|
||||
apiClient.get<Transaction>(`/api/payments/transactions/${id}/`);
|
||||
|
||||
/**
|
||||
* Get transaction summary/analytics.
|
||||
*/
|
||||
export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<TransactionSummary>(
|
||||
`/api/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get charges from Stripe API.
|
||||
*/
|
||||
export const getStripeCharges = (limit: number = 20) =>
|
||||
apiClient.get<ChargesResponse>(`/api/payments/transactions/charges/?limit=${limit}`);
|
||||
|
||||
/**
|
||||
* Get payouts from Stripe API.
|
||||
*/
|
||||
export const getStripePayouts = (limit: number = 20) =>
|
||||
apiClient.get<PayoutsResponse>(`/api/payments/transactions/payouts/?limit=${limit}`);
|
||||
|
||||
/**
|
||||
* Get current balance from Stripe API.
|
||||
*/
|
||||
export const getStripeBalance = () =>
|
||||
apiClient.get<BalanceResponse>('/api/payments/transactions/balance/');
|
||||
|
||||
/**
|
||||
* Export transaction data.
|
||||
* Returns the file data directly for download.
|
||||
*/
|
||||
export const exportTransactions = (request: ExportRequest) =>
|
||||
apiClient.post('/api/payments/transactions/export/', request, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Details & Refunds
|
||||
// ============================================================================
|
||||
|
||||
export interface RefundInfo {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
status: string;
|
||||
reason: string | null;
|
||||
created: number;
|
||||
}
|
||||
|
||||
export interface PaymentMethodInfo {
|
||||
type: string;
|
||||
brand?: string;
|
||||
last4?: string;
|
||||
exp_month?: number;
|
||||
exp_year?: number;
|
||||
funding?: string;
|
||||
bank_name?: string;
|
||||
}
|
||||
|
||||
export interface TransactionDetail extends Transaction {
|
||||
refunds: RefundInfo[];
|
||||
refundable_amount: number;
|
||||
total_refunded: number;
|
||||
can_refund: boolean;
|
||||
payment_method_info: PaymentMethodInfo | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RefundRequest {
|
||||
amount?: number;
|
||||
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RefundResponse {
|
||||
success: boolean;
|
||||
refund_id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
status: string;
|
||||
reason: string | null;
|
||||
transaction_status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed transaction information including refund data.
|
||||
*/
|
||||
export const getTransactionDetail = (id: number) =>
|
||||
apiClient.get<TransactionDetail>(`/api/payments/transactions/${id}/`);
|
||||
|
||||
/**
|
||||
* Issue a refund for a transaction.
|
||||
* @param transactionId - The ID of the transaction to refund
|
||||
* @param request - Optional refund request with amount and reason
|
||||
*/
|
||||
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
|
||||
apiClient.post<RefundResponse>(`/api/payments/transactions/${transactionId}/refund/`, request || {});
|
||||
56
frontend/src/api/platform.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Platform API
|
||||
* API functions for platform-level operations (businesses, users, etc.)
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface PlatformBusiness {
|
||||
id: number;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
tier: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface PlatformUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business: number | null;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
date_joined: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all businesses (platform admin only)
|
||||
*/
|
||||
export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
|
||||
const response = await apiClient.get<PlatformBusiness[]>('/api/platform/businesses/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users (platform admin only)
|
||||
*/
|
||||
export const getUsers = async (): Promise<PlatformUser[]> => {
|
||||
const response = await apiClient.get<PlatformUser[]>('/api/platform/users/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get users for a specific business
|
||||
*/
|
||||
export const getBusinessUsers = async (businessId: number): Promise<PlatformUser[]> => {
|
||||
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
|
||||
return response.data;
|
||||
};
|
||||
90
frontend/src/api/platformOAuth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Platform OAuth Settings API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
enabled: boolean;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
// Apple-specific fields
|
||||
team_id?: string;
|
||||
key_id?: string;
|
||||
// Microsoft-specific field
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
export interface PlatformOAuthSettings {
|
||||
// Global setting
|
||||
oauth_allow_registration: boolean;
|
||||
|
||||
// Provider configurations
|
||||
google: OAuthProviderConfig;
|
||||
apple: OAuthProviderConfig;
|
||||
facebook: OAuthProviderConfig;
|
||||
linkedin: OAuthProviderConfig;
|
||||
microsoft: OAuthProviderConfig;
|
||||
twitter: OAuthProviderConfig;
|
||||
twitch: OAuthProviderConfig;
|
||||
}
|
||||
|
||||
export interface PlatformOAuthSettingsUpdate {
|
||||
oauth_allow_registration?: boolean;
|
||||
|
||||
// Google
|
||||
oauth_google_enabled?: boolean;
|
||||
oauth_google_client_id?: string;
|
||||
oauth_google_client_secret?: string;
|
||||
|
||||
// Apple
|
||||
oauth_apple_enabled?: boolean;
|
||||
oauth_apple_client_id?: string;
|
||||
oauth_apple_client_secret?: string;
|
||||
oauth_apple_team_id?: string;
|
||||
oauth_apple_key_id?: string;
|
||||
|
||||
// Facebook
|
||||
oauth_facebook_enabled?: boolean;
|
||||
oauth_facebook_client_id?: string;
|
||||
oauth_facebook_client_secret?: string;
|
||||
|
||||
// LinkedIn
|
||||
oauth_linkedin_enabled?: boolean;
|
||||
oauth_linkedin_client_id?: string;
|
||||
oauth_linkedin_client_secret?: string;
|
||||
|
||||
// Microsoft
|
||||
oauth_microsoft_enabled?: boolean;
|
||||
oauth_microsoft_client_id?: string;
|
||||
oauth_microsoft_client_secret?: string;
|
||||
oauth_microsoft_tenant_id?: string;
|
||||
|
||||
// Twitter (X)
|
||||
oauth_twitter_enabled?: boolean;
|
||||
oauth_twitter_client_id?: string;
|
||||
oauth_twitter_client_secret?: string;
|
||||
|
||||
// Twitch
|
||||
oauth_twitch_enabled?: boolean;
|
||||
oauth_twitch_client_id?: string;
|
||||
oauth_twitch_client_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform OAuth settings
|
||||
*/
|
||||
export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings> => {
|
||||
const { data } = await apiClient.get('/api/platform/settings/oauth/');
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update platform OAuth settings
|
||||
*/
|
||||
export const updatePlatformOAuthSettings = async (
|
||||
settings: PlatformOAuthSettingsUpdate
|
||||
): Promise<PlatformOAuthSettings> => {
|
||||
const { data } = await apiClient.post('/api/platform/settings/oauth/', settings);
|
||||
return data;
|
||||
};
|
||||
210
frontend/src/api/profile.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
phone_verified: boolean;
|
||||
avatar_url?: string;
|
||||
email_verified: boolean;
|
||||
two_factor_enabled: boolean;
|
||||
totp_confirmed: boolean;
|
||||
sms_2fa_enabled: boolean;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
notification_preferences: NotificationPreferences;
|
||||
role: string;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
// Address fields
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
in_app: boolean;
|
||||
appointment_reminders: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code: string; // Base64 encoded PNG
|
||||
provisioning_uri: string;
|
||||
}
|
||||
|
||||
export interface TOTPVerifyResponse {
|
||||
success: boolean;
|
||||
recovery_codes: string[];
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
device_info: string;
|
||||
ip_address: string;
|
||||
location: string;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
is_current: boolean;
|
||||
}
|
||||
|
||||
export interface LoginHistoryEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
ip_address: string;
|
||||
device_info: string;
|
||||
location: string;
|
||||
success: boolean;
|
||||
failure_reason?: string;
|
||||
two_factor_method?: string;
|
||||
}
|
||||
|
||||
// Profile API
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await apiClient.get('/api/auth/profile/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProfile = async (data: Partial<UserProfile>): Promise<UserProfile> => {
|
||||
const response = await apiClient.patch('/api/auth/profile/', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAvatar = async (file: File): Promise<{ avatar_url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const response = await apiClient.post('/api/auth/profile/avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAvatar = async (): Promise<void> => {
|
||||
await apiClient.delete('/api/auth/profile/avatar/');
|
||||
};
|
||||
|
||||
// Email API
|
||||
export const sendVerificationEmail = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/verify/send/');
|
||||
};
|
||||
|
||||
export const verifyEmail = async (token: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/verify/confirm/', { token });
|
||||
};
|
||||
|
||||
export const requestEmailChange = async (newEmail: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/change/', { new_email: newEmail });
|
||||
};
|
||||
|
||||
export const confirmEmailChange = async (token: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/change/confirm/', { token });
|
||||
};
|
||||
|
||||
// Password API
|
||||
export const changePassword = async (
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> => {
|
||||
await apiClient.post('/api/auth/password/change/', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
// 2FA API
|
||||
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const disableTOTP = async (code: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
|
||||
};
|
||||
|
||||
export const getRecoveryCodes = async (): Promise<string[]> => {
|
||||
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
|
||||
return response.data.codes;
|
||||
};
|
||||
|
||||
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
|
||||
return response.data.codes;
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
export const getSessions = async (): Promise<Session[]> => {
|
||||
const response = await apiClient.get('/api/auth/sessions/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const revokeSession = async (sessionId: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/sessions/${sessionId}/`);
|
||||
};
|
||||
|
||||
export const revokeOtherSessions = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/sessions/revoke-others/');
|
||||
};
|
||||
|
||||
export const getLoginHistory = async (): Promise<LoginHistoryEntry[]> => {
|
||||
const response = await apiClient.get('/api/auth/login-history/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Phone Verification API
|
||||
export const sendPhoneVerification = async (phone: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/phone/verify/send/', { phone });
|
||||
};
|
||||
|
||||
export const verifyPhoneCode = async (code: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/phone/verify/confirm/', { code });
|
||||
};
|
||||
|
||||
// Multiple Email Management API
|
||||
export interface UserEmail {
|
||||
id: number;
|
||||
email: string;
|
||||
is_primary: boolean;
|
||||
verified: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getUserEmails = async (): Promise<UserEmail[]> => {
|
||||
const response = await apiClient.get('/api/auth/emails/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const addUserEmail = async (email: string): Promise<UserEmail> => {
|
||||
const response = await apiClient.post('/api/auth/emails/', { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteUserEmail = async (emailId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/emails/${emailId}/`);
|
||||
};
|
||||
|
||||
export const sendUserEmailVerification = async (emailId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/send-verification/`);
|
||||
};
|
||||
|
||||
export const verifyUserEmail = async (emailId: number, token: string): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/verify/`, { token });
|
||||
};
|
||||
|
||||
export const setPrimaryEmail = async (emailId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/set-primary/`);
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/smooth_schedule_icon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
247
frontend/src/assets/smooth_schedule_icon.svg
Normal file
@@ -0,0 +1,247 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 1730 1100" enable-background="new 0 0 1730 1100" xml:space="preserve">
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M969.645508,771.667175
|
||||
C983.734009,760.932678 998.024170,750.981995 1011.135925,739.665955
|
||||
C1020.239868,731.809021 1027.811401,722.153137 1035.890625,713.142212
|
||||
C1039.635864,708.964966 1042.988037,704.431946 1046.455933,700.011230
|
||||
C1047.427979,698.771973 1048.177979,697.358459 1048.318481,695.266968
|
||||
C1043.233154,698.355286 1038.068848,701.321533 1033.076172,704.552979
|
||||
C1011.285095,718.656555 987.633118,729.002747 963.865662,739.154541
|
||||
C926.816467,754.979309 888.330383,766.524841 849.335266,776.096252
|
||||
C816.661194,784.116150 783.571899,790.540527 750.510559,796.854858
|
||||
C725.879822,801.559082 701.040466,805.235657 676.219849,808.862976
|
||||
C650.730042,812.588013 625.020874,814.936829 599.640015,819.253784
|
||||
C561.088013,825.810913 522.543823,832.670288 484.360413,841.058594
|
||||
C453.594025,847.817566 423.045654,856.010376 393.000458,865.471924
|
||||
C368.607147,873.153687 344.985138,883.370483 321.251038,893.028137
|
||||
C306.543671,899.012756 291.840790,905.266846 277.895966,912.805786
|
||||
C257.393433,923.890198 237.239243,935.709290 217.557373,948.193909
|
||||
C200.561569,958.974670 183.671112,970.190491 168.118774,982.907349
|
||||
C150.190521,997.567078 133.454575,1013.749817 116.860298,1029.946777
|
||||
C109.343819,1037.283325 103.407921,1046.241699 96.785301,1054.488647
|
||||
C95.963615,1055.511963 95.329086,1056.685547 94.607811,1057.789551
|
||||
C94.087418,1057.428833 93.567024,1057.068237 93.046631,1056.707520
|
||||
C95.143036,1051.902710 97.038155,1046.997681 99.369362,1042.309692
|
||||
C112.070229,1016.768616 126.605263,992.265686 143.560577,969.362183
|
||||
C154.371017,954.759155 166.268524,940.905212 178.350021,927.312134
|
||||
C195.337433,908.199402 214.588501,891.460449 234.166809,874.999146
|
||||
C257.180664,855.649292 281.649719,838.427429 307.573792,823.472717
|
||||
C336.247131,806.932129 365.478760,791.285645 396.623840,779.614136
|
||||
C412.184509,773.782776 427.375671,766.898743 443.113495,761.622986
|
||||
C464.384369,754.492371 485.846008,747.786011 507.540192,742.096313
|
||||
C540.973694,733.327393 574.554077,725.033386 608.300049,717.568359
|
||||
C634.070862,711.867554 660.204224,707.813232 686.158875,702.934082
|
||||
C711.461548,698.177490 736.731262,693.245178 762.037231,688.506470
|
||||
C773.996765,686.266907 786.117065,684.782654 797.967041,682.087830
|
||||
C813.228760,678.617249 828.417358,674.693970 843.413147,670.214722
|
||||
C868.431335,662.742126 893.816467,656.055481 918.046326,646.500244
|
||||
C947.249329,634.983948 975.782898,621.687195 1001.597900,603.148926
|
||||
C1019.638672,590.193542 1038.112427,577.639526 1057.397705,566.673218
|
||||
C1078.458008,554.697449 1100.290771,544.266785 1123.296509,535.835693
|
||||
C1153.968750,524.595032 1185.606567,517.348511 1216.895020,508.623566
|
||||
C1228.170898,505.479218 1239.672241,503.140717 1251.080444,500.476807
|
||||
C1252.144653,500.228271 1253.280396,500.285614 1255.739990,500.096741
|
||||
C1253.853149,502.287323 1252.808350,503.648651 1251.613892,504.862793
|
||||
C1244.387573,512.208191 1237.151611,519.453979 1230.673462,527.585815
|
||||
C1218.089600,543.381836 1207.873535,560.547852 1199.297607,578.655396
|
||||
C1191.381104,595.370850 1184.464722,612.563843 1177.247925,629.605774
|
||||
C1168.326660,650.672302 1158.144165,671.075928 1146.017334,690.496033
|
||||
C1135.214478,707.795898 1123.201904,724.184570 1109.329102,739.153809
|
||||
C1098.717407,750.604187 1088.405151,762.391296 1077.100098,773.122986
|
||||
C1066.321655,783.354675 1054.340088,792.309082 1043.048340,802.012085
|
||||
C1022.812439,819.400757 999.674561,832.426270 976.316162,844.731873
|
||||
C956.019775,855.424316 934.888245,864.927551 913.308838,872.683411
|
||||
C889.113220,881.379639 864.222961,888.452698 839.208069,894.452332
|
||||
C822.112122,898.552673 804.305725,899.769409 786.777283,901.957336
|
||||
C776.820679,903.200073 766.784119,903.802368 755.687805,904.790649
|
||||
C757.714539,906.218933 759.003662,907.369080 760.489807,908.138672
|
||||
C783.668091,920.140076 807.284790,931.020691 832.462219,938.359375
|
||||
C855.906860,945.193054 879.583191,951.030334 903.823364,953.736511
|
||||
C919.614380,955.499451 935.650452,956.242859 951.530090,955.794312
|
||||
C985.213318,954.842834 1018.249756,949.116272 1050.425049,938.980957
|
||||
C1090.859131,926.244141 1128.350220,907.577209 1162.281494,882.076538
|
||||
C1172.054565,874.731628 1181.528320,866.922607 1190.604370,858.733459
|
||||
C1201.177246,849.194031 1211.503418,839.328491 1221.327759,829.023743
|
||||
C1238.017578,811.517944 1253.516968,792.980530 1265.936401,772.111816
|
||||
C1274.501709,757.719238 1283.092041,743.320740 1291.001709,728.565918
|
||||
C1296.228638,718.815796 1300.504639,708.528442 1304.793457,698.308411
|
||||
C1315.707275,672.301758 1322.893799,645.154175 1327.839600,617.478088
|
||||
C1330.420410,603.036621 1331.911011,588.336731 1332.869995,573.686584
|
||||
C1333.878906,558.275757 1334.407471,542.754089 1333.765503,527.338318
|
||||
C1333.190186,513.526611 1330.652344,499.801727 1329.257446,486.010529
|
||||
C1329.129883,484.748444 1330.735107,482.487030 1332.013306,482.032196
|
||||
C1347.430786,476.546417 1363.083862,471.698395 1378.384033,465.913452
|
||||
C1395.856812,459.307068 1413.124390,452.132050 1430.313843,444.811279
|
||||
C1442.720703,439.527374 1455.204834,434.247284 1467.033081,427.821472
|
||||
C1488.682861,416.060059 1510.133179,403.880432 1531.183105,391.080017
|
||||
C1553.192505,377.696136 1573.413086,361.740723 1592.717285,344.700775
|
||||
C1602.850830,335.755951 1612.603027,326.373413 1622.384766,317.039001
|
||||
C1625.733643,313.843140 1628.616577,310.162659 1631.782593,306.769073
|
||||
C1632.601929,305.891022 1633.686157,305.260193 1634.648682,304.515747
|
||||
C1634.988770,304.771484 1635.328979,305.027191 1635.669067,305.282928
|
||||
C1633.291504,309.465271 1631.207642,313.850372 1628.485229,317.794739
|
||||
C1616.850464,334.652039 1605.817017,352.011719 1593.041870,367.969421
|
||||
C1581.144165,382.831451 1568.030884,396.904633 1554.180420,409.977081
|
||||
C1532.040161,430.873718 1508.570923,450.362701 1482.932861,466.917206
|
||||
C1461.684692,480.637024 1440.099609,493.861084 1418.288452,506.665344
|
||||
C1412.599854,510.004883 1412.874390,514.199585 1413.025269,519.129028
|
||||
C1413.335327,529.252197 1413.837646,539.372375 1413.970093,549.497498
|
||||
C1414.420044,583.932129 1409.491089,617.730225 1402.481934,651.371643
|
||||
C1396.489746,680.130859 1388.232544,708.150024 1376.739746,735.113281
|
||||
C1365.900146,760.543701 1354.243652,785.647095 1338.789673,808.736450
|
||||
C1329.595947,822.472168 1320.811768,836.521423 1310.929565,849.746643
|
||||
C1299.263916,865.358582 1287.147461,880.676086 1272.680908,893.951477
|
||||
C1267.312744,898.877502 1262.994141,904.960815 1257.552490,909.790405
|
||||
C1244.686401,921.209106 1231.741821,932.582581 1218.245483,943.234863
|
||||
C1206.325317,952.643250 1194.096924,961.842163 1181.146973,969.724365
|
||||
C1158.948486,983.235718 1135.947021,995.433838 1111.749023,1005.062805
|
||||
C1097.940796,1010.557495 1084.021851,1016.069458 1069.709839,1019.932617
|
||||
C1049.345581,1025.429443 1028.718628,1030.276367 1007.929993,1033.773071
|
||||
C991.841858,1036.479248 975.354248,1037.157715 959.006348,1037.869873
|
||||
C944.148682,1038.517090 929.177429,1038.851318 914.369873,1037.769165
|
||||
C895.950500,1036.422974 877.505005,1034.281860 859.326843,1031.045898
|
||||
C829.348206,1025.709351 800.144714,1017.064575 771.925598,1005.670044
|
||||
C756.608765,999.485352 741.639099,992.105408 727.324646,983.855835
|
||||
C708.068115,972.758301 689.407349,960.583984 670.880676,948.284729
|
||||
C663.679993,943.504333 657.602966,937.049927 650.901428,931.493958
|
||||
C644.098328,925.853760 636.800903,920.762085 630.356140,914.748413
|
||||
C619.933044,905.022461 609.957886,894.810120 599.945679,884.653320
|
||||
C596.250183,880.904480 592.985229,876.731201 588.732971,871.842102
|
||||
C593.234985,871.136841 596.812500,870.434021 600.423706,870.036133
|
||||
C613.563843,868.588135 626.724976,867.327454 639.859131,865.829712
|
||||
C649.863892,864.688843 659.866699,863.473877 669.822815,861.976013
|
||||
C687.452637,859.323730 705.044434,856.420166 722.655457,853.642822
|
||||
C738.960144,851.071533 755.332520,848.871826 771.558411,845.876282
|
||||
C788.103882,842.821716 804.617798,839.442139 820.942810,835.388611
|
||||
C838.621033,830.999084 856.189697,826.061890 873.562744,820.590759
|
||||
C883.364563,817.504089 892.799072,813.130615 902.178589,808.853394
|
||||
C914.899170,803.052734 927.670898,797.269348 939.927246,790.575073
|
||||
C950.102966,785.017090 959.565613,778.153442 969.645508,771.667175
|
||||
M1050.581421,694.050720
|
||||
C1050.730957,693.806946 1050.880493,693.563171 1051.029907,693.319275
|
||||
C1050.812500,693.437988 1050.595093,693.556763 1050.581421,694.050720
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M556.660950,457.603760
|
||||
C541.109375,531.266846 547.394165,603.414612 568.399292,675.217285
|
||||
C432.503021,704.469421 306.741730,754.212341 199.911194,846.845520
|
||||
C200.479172,845.300049 200.602173,844.107422 201.242157,843.353882
|
||||
C209.385620,833.765381 217.337875,823.994263 225.877579,814.768311
|
||||
C234.207504,805.768921 242.989990,797.166687 251.896179,788.730408
|
||||
C257.379120,783.536743 263.590637,779.120728 269.241333,774.092896
|
||||
C273.459808,770.339478 276.960907,765.728882 281.376740,762.257324
|
||||
C297.837646,749.316223 314.230652,736.249023 331.255981,724.078125
|
||||
C345.231110,714.087769 359.912170,705.048035 374.584686,696.085144
|
||||
C382.450134,691.280396 391.044617,687.685791 399.150726,683.253601
|
||||
C407.072968,678.921997 414.597321,673.833801 422.642975,669.762817
|
||||
C438.151398,661.916077 453.798492,654.321594 469.600006,647.085205
|
||||
C477.539642,643.449280 478.113831,642.479065 476.519012,633.766968
|
||||
C474.589203,623.224731 473.630249,612.508850 471.947601,601.916382
|
||||
C467.749847,575.490784 468.654633,548.856323 469.237122,522.316833
|
||||
C469.602295,505.676849 471.616699,488.988892 474.083252,472.500793
|
||||
C477.059357,452.606354 480.060059,432.564514 485.320496,413.203339
|
||||
C491.148651,391.752808 499.099060,370.831879 506.971741,350.000183
|
||||
C512.325867,335.832855 518.620361,321.957916 525.397888,308.404053
|
||||
C541.421509,276.359467 560.144958,245.828873 582.862244,218.156967
|
||||
C598.004089,199.712769 614.822388,182.523621 631.949951,165.861969
|
||||
C652.972046,145.411667 676.340942,127.695229 701.137573,111.953148
|
||||
C726.902954,95.596024 753.783325,81.411240 782.541138,71.040688
|
||||
C797.603638,65.608902 812.617126,59.820137 828.057861,55.716591
|
||||
C845.892639,50.976776 864.121277,47.634624 882.284790,44.254494
|
||||
C890.218506,42.778072 898.403564,42.346916 906.495300,42.086170
|
||||
C924.443237,41.507816 942.445129,40.435017 960.349243,41.242741
|
||||
C979.963135,42.127602 999.561890,44.377670 1019.039673,46.986061
|
||||
C1043.176270,50.218334 1066.758545,56.365486 1089.506470,64.964005
|
||||
C1106.661865,71.448593 1123.305542,79.342972 1139.969482,87.057976
|
||||
C1162.813843,97.634354 1183.941406,111.123840 1204.113037,126.138229
|
||||
C1207.003540,128.289703 1209.946899,130.370087 1213.763916,133.133530
|
||||
C1216.783447,129.931229 1220.327026,126.716408 1223.223755,122.997650
|
||||
C1231.400269,112.500671 1239.273560,101.768028 1247.343994,91.187546
|
||||
C1251.051270,86.327263 1254.881470,81.556633 1258.788696,76.855797
|
||||
C1259.760620,75.686508 1261.248413,74.945900 1262.499023,74.008209
|
||||
C1263.292480,75.345688 1264.457031,76.590347 1264.818726,78.035873
|
||||
C1267.046143,86.937248 1268.891724,95.937119 1271.242432,104.803978
|
||||
C1275.496948,120.851143 1280.156372,136.791153 1284.390381,152.843521
|
||||
C1289.730957,173.090820 1294.707275,193.434189 1300.038086,213.684174
|
||||
C1305.998291,236.325089 1312.179443,258.907806 1318.265015,281.515717
|
||||
C1318.472290,282.285461 1318.685059,283.053680 1319.249390,285.117157
|
||||
C1249.010864,270.419495 1179.575439,255.889877 1109.182129,241.159790
|
||||
C1125.300659,224.247345 1141.057739,207.714233 1157.271729,190.701782
|
||||
C1151.530518,186.784927 1146.192871,182.681778 1140.429565,179.305191
|
||||
C1127.437134,171.693329 1114.523315,163.859375 1101.056763,157.163300
|
||||
C1072.803589,143.114868 1043.187866,132.633057 1012.025146,127.306679
|
||||
C996.903809,124.722130 981.545776,123.292236 966.235352,122.115311
|
||||
C953.661621,121.148743 940.985535,120.787796 928.380005,121.118324
|
||||
C905.687134,121.713341 883.266846,125.033134 861.164490,130.156235
|
||||
C827.750183,137.901321 796.099426,150.481354 765.943542,166.659683
|
||||
C744.045410,178.407761 723.100586,191.717087 704.741150,208.715820
|
||||
C692.812561,219.760330 680.168945,230.111618 668.980225,241.854492
|
||||
C657.360962,254.049179 646.193909,266.898956 636.478516,280.629303
|
||||
C622.844910,299.897369 609.775757,319.708069 598.278931,340.299286
|
||||
C589.203308,356.553925 582.410522,374.153534 575.426636,391.487335
|
||||
C567.199646,411.906586 561.340576,433.110779 557.061401,454.725311
|
||||
C556.900940,455.536224 556.898621,456.378479 556.660950,457.603760
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1706.087402,107.067314
|
||||
C1703.089111,115.619484 1700.499512,124.342247 1697.015747,132.691925
|
||||
C1686.536865,157.806900 1674.552490,182.225861 1658.662109,204.387024
|
||||
C1646.541138,221.290833 1633.860840,237.859802 1620.447754,253.749130
|
||||
C1610.171387,265.922516 1598.887085,277.376678 1587.116699,288.127747
|
||||
C1567.458008,306.083740 1546.417847,322.320587 1524.483398,337.552246
|
||||
C1495.366455,357.771515 1464.521729,374.787689 1432.470215,389.522156
|
||||
C1408.761597,400.421356 1384.338989,409.873322 1359.856445,418.950348
|
||||
C1338.651123,426.812286 1317.005859,433.538574 1295.377563,440.189545
|
||||
C1282.541626,444.136749 1269.303589,446.756866 1256.353271,450.357635
|
||||
C1243.725464,453.868683 1231.256226,457.945312 1218.677490,461.637756
|
||||
C1192.216675,469.405334 1165.581299,476.616241 1139.306396,484.964813
|
||||
C1122.046509,490.448944 1105.143555,497.158905 1088.355957,503.995453
|
||||
C1073.956177,509.859589 1059.653931,516.113403 1045.836670,523.221436
|
||||
C1027.095337,532.862488 1009.846802,544.765564 994.656799,559.637390
|
||||
C986.521912,567.601807 977.590271,574.817322 968.586731,581.815613
|
||||
C950.906799,595.557678 919.261353,591.257507 902.949524,575.751221
|
||||
C890.393311,563.815002 883.972961,548.799927 878.270020,533.265137
|
||||
C872.118042,516.506958 862.364990,502.109009 851.068176,488.567474
|
||||
C837.824646,472.692474 824.675781,456.737396 811.315308,440.961517
|
||||
C803.714661,431.986664 795.703918,423.360413 788.002930,414.468903
|
||||
C778.470581,403.462769 769.106140,392.311340 759.596680,381.285126
|
||||
C752.240295,372.755371 744.606201,364.460114 737.400879,355.806427
|
||||
C730.120544,347.062592 727.078613,337.212921 730.571777,325.824554
|
||||
C736.598145,306.177765 760.405457,299.432281 775.159790,313.874237
|
||||
C789.284302,327.699738 802.566406,342.385803 816.219543,356.692902
|
||||
C816.564697,357.054535 816.916931,357.409424 817.268250,357.765015
|
||||
C845.125427,385.956726 872.707642,414.429291 901.026123,442.149719
|
||||
C908.467834,449.434296 918.068054,454.575775 926.906189,460.350739
|
||||
C933.051758,464.366333 939.634460,467.707123 945.928894,471.503540
|
||||
C948.467102,473.034454 950.404358,472.612885 952.644043,470.884766
|
||||
C972.771118,455.355255 994.347229,442.156677 1017.344299,431.336121
|
||||
C1045.954834,417.874298 1075.032959,405.539398 1105.177612,395.868073
|
||||
C1127.357422,388.752136 1149.351074,380.949371 1171.792480,374.778931
|
||||
C1209.532104,364.402008 1247.646118,355.393494 1285.443359,345.217163
|
||||
C1317.973999,336.458740 1350.391968,327.239716 1382.656372,317.547119
|
||||
C1412.278198,308.648407 1441.114014,297.585785 1469.434570,285.016663
|
||||
C1511.778687,266.223450 1552.020386,243.841415 1590.082031,217.493744
|
||||
C1608.183228,204.963440 1625.881104,191.914856 1641.874268,176.685043
|
||||
C1649.680786,169.250977 1658.483398,162.733627 1665.537964,154.666931
|
||||
C1679.129517,139.125244 1691.799438,122.777374 1705.282837,106.722069
|
||||
C1705.838623,106.809341 1705.963135,106.938339 1706.087402,107.067314
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1705.527710,106.486694
|
||||
C1705.630859,105.936501 1705.907837,105.568420 1706.218628,105.231407
|
||||
C1706.297485,105.145935 1706.483765,105.159477 1706.930664,105.055130
|
||||
C1706.771118,105.730949 1706.643921,106.269272 1706.302246,106.937462
|
||||
C1705.963135,106.938339 1705.838623,106.809341 1705.527710,106.486694
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M196.850372,849.515076
|
||||
C196.898865,849.852539 196.767776,850.076172 196.636688,850.299805
|
||||
C196.648056,850.000305 196.659409,849.700745 196.850372,849.515076
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
89
frontend/src/components/AppointmentConfirmation.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.confirmation-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmation-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.confirmation-container h2 {
|
||||
font-size: 2rem;
|
||||
color: #1a202c;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.confirmation-details {
|
||||
background: #f7fafc;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
color: #718096;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-done {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-done:hover {
|
||||
background: #2c5282;
|
||||
}
|
||||
39
frontend/src/components/AppointmentConfirmation.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import './AppointmentConfirmation.css';
|
||||
|
||||
const AppointmentConfirmation = ({ appointment, onClose }) => {
|
||||
const startTime = new Date(appointment.start_time);
|
||||
|
||||
return (
|
||||
<div className="confirmation-container">
|
||||
<div className="confirmation-icon">✓</div>
|
||||
<h2>Booking Confirmed!</h2>
|
||||
|
||||
<div className="confirmation-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Date:</span>
|
||||
<span className="detail-value">{format(startTime, 'MMMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Time:</span>
|
||||
<span className="detail-value">{format(startTime, 'h:mm a')}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Status:</span>
|
||||
<span className="detail-value status-badge">{appointment.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="confirmation-message">
|
||||
You will receive a confirmation email shortly with all the details.
|
||||
</p>
|
||||
|
||||
<button onClick={onClose} className="btn-done">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppointmentConfirmation;
|
||||
137
frontend/src/components/BookingForm.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.booking-form-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.booking-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.booking-form-header h2 {
|
||||
font-size: 1.75rem;
|
||||
color: #1a202c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.service-summary {
|
||||
background: #f7fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.service-summary p {
|
||||
margin: 0.5rem 0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.booking-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3182ce;
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group select.error {
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e53e3e;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-submit {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #edf2f7;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
133
frontend/src/components/BookingForm.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import './BookingForm.css';
|
||||
|
||||
const BookingForm = ({ service, resources, onSubmit, onCancel, loading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
resource: resources?.[0]?.id || '',
|
||||
date: '',
|
||||
time: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.resource) {
|
||||
newErrors.resource = 'Please select a resource';
|
||||
}
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'Please select a date';
|
||||
}
|
||||
if (!formData.time) {
|
||||
newErrors.time = 'Please select a time';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine date and time into ISO format
|
||||
const startDateTime = new Date(`${formData.date}T${formData.time}`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + service.duration * 60000);
|
||||
|
||||
const appointmentData = {
|
||||
service: service.id,
|
||||
resource: parseInt(formData.resource),
|
||||
start_time: startDateTime.toISOString(),
|
||||
end_time: endDateTime.toISOString(),
|
||||
};
|
||||
|
||||
onSubmit(appointmentData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="booking-form-container">
|
||||
<div className="booking-form-header">
|
||||
<h2>Book: {service.name}</h2>
|
||||
<button onClick={onCancel} className="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div className="service-summary">
|
||||
<p><strong>Duration:</strong> {service.duration} minutes</p>
|
||||
<p><strong>Price:</strong> ${service.price}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="booking-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="resource">Select Provider</label>
|
||||
<select
|
||||
id="resource"
|
||||
name="resource"
|
||||
value={formData.resource}
|
||||
onChange={handleChange}
|
||||
className={errors.resource ? 'error' : ''}
|
||||
>
|
||||
<option value="">Choose a provider...</option>
|
||||
{resources?.map((resource) => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.resource && <span className="error-message">{errors.resource}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="date">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
className={errors.date ? 'error' : ''}
|
||||
/>
|
||||
{errors.date && <span className="error-message">{errors.date}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="time">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className={errors.time ? 'error' : ''}
|
||||
/>
|
||||
{errors.time && <span className="error-message">{errors.time}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={onCancel} className="btn-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-submit" disabled={loading}>
|
||||
{loading ? 'Booking...' : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingForm;
|
||||
269
frontend/src/components/ConnectOnboarding.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Stripe Connect Onboarding Component
|
||||
* For paid-tier businesses to connect their Stripe account via Connect
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { ConnectAccountInfo } from '../api/payments';
|
||||
import { useConnectOnboarding, useRefreshConnectLink } from '../hooks/usePayments';
|
||||
|
||||
interface ConnectOnboardingProps {
|
||||
connectAccount: ConnectAccountInfo | null;
|
||||
tier: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
connectAccount,
|
||||
tier,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onboardingMutation = useConnectOnboarding();
|
||||
const refreshLinkMutation = useRefreshConnectLink();
|
||||
|
||||
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
||||
const isOnboarding = connectAccount?.status === 'onboarding' ||
|
||||
(connectAccount && !connectAccount.onboarding_complete);
|
||||
const needsOnboarding = !connectAccount;
|
||||
|
||||
const getReturnUrls = () => {
|
||||
const baseUrl = window.location.origin;
|
||||
return {
|
||||
refreshUrl: `${baseUrl}/payments?connect=refresh`,
|
||||
returnUrl: `${baseUrl}/payments?connect=complete`,
|
||||
};
|
||||
};
|
||||
|
||||
const handleStartOnboarding = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { refreshUrl, returnUrl } = getReturnUrls();
|
||||
const result = await onboardingMutation.mutateAsync({ refreshUrl, returnUrl });
|
||||
// Redirect to Stripe onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to start onboarding');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshLink = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { refreshUrl, returnUrl } = getReturnUrls();
|
||||
const result = await refreshLinkMutation.mutateAsync({ refreshUrl, returnUrl });
|
||||
// Redirect to continue onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
|
||||
}
|
||||
};
|
||||
|
||||
// Account type display
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
default:
|
||||
return 'Connect';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Active Account Status */}
|
||||
{isActive && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800">Stripe Connected</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Details */}
|
||||
{connectAccount && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Type:</span>
|
||||
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
connectAccount.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: connectAccount.status === 'onboarding'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: connectAccount.status === 'restricted'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{connectAccount.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Charges:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.charges_enabled ? (
|
||||
<>
|
||||
<CreditCard size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Payouts:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.payouts_enabled ? (
|
||||
<>
|
||||
<Wallet size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wallet size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{connectAccount.stripe_account_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account ID:</span>
|
||||
<code className="font-mono text-gray-900 text-xs">
|
||||
{connectAccount.stripe_account_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding in Progress */}
|
||||
{isOnboarding && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Your Stripe Connect account setup is incomplete.
|
||||
Click below to continue the onboarding process.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRefreshLink}
|
||||
disabled={refreshLinkMutation.isPending}
|
||||
className="mt-3 flex items-center gap-2 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200 disabled:opacity-50"
|
||||
>
|
||||
{refreshLinkMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Continue Onboarding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Onboarding */}
|
||||
{needsOnboarding && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
This provides a seamless payment experience for your customers while
|
||||
the platform handles payment processing.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartOnboarding}
|
||||
disabled={onboardingMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] disabled:opacity-50"
|
||||
>
|
||||
{onboardingMutation.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={18} />
|
||||
Connect with Stripe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2 text-red-800">
|
||||
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Stripe Dashboard Link */}
|
||||
{isActive && (
|
||||
<a
|
||||
href="https://dashboard.stripe.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open Stripe Dashboard
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectOnboarding;
|
||||
290
frontend/src/components/ConnectOnboardingEmbed.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Embedded Stripe Connect Onboarding Component
|
||||
*
|
||||
* Uses Stripe's Connect embedded components to provide a seamless
|
||||
* onboarding experience without redirecting users away from the app.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
ConnectComponentsProvider,
|
||||
ConnectAccountOnboarding,
|
||||
} from '@stripe/react-connect-js';
|
||||
import { loadConnectAndInitialize } from '@stripe/connect-js';
|
||||
import type { StripeConnectInstance } from '@stripe/connect-js';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
||||
|
||||
interface ConnectOnboardingEmbedProps {
|
||||
connectAccount: ConnectAccountInfo | null;
|
||||
tier: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
|
||||
|
||||
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
connectAccount,
|
||||
tier,
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
||||
|
||||
// Initialize Stripe Connect
|
||||
const initializeStripeConnect = useCallback(async () => {
|
||||
if (loadingState === 'loading' || loadingState === 'ready') return;
|
||||
|
||||
setLoadingState('loading');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Fetch account session from our backend
|
||||
const response = await createAccountSession();
|
||||
const { client_secret, publishable_key } = response.data;
|
||||
|
||||
// Initialize the Connect instance
|
||||
const instance = await loadConnectAndInitialize({
|
||||
publishableKey: publishable_key,
|
||||
fetchClientSecret: async () => client_secret,
|
||||
appearance: {
|
||||
overlays: 'drawer',
|
||||
variables: {
|
||||
colorPrimary: '#635BFF',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#1a1a1a',
|
||||
colorDanger: '#df1b41',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSizeBase: '14px',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setStripeConnectInstance(instance);
|
||||
setLoadingState('ready');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to initialize Stripe Connect:', err);
|
||||
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}
|
||||
}, [loadingState, onError]);
|
||||
|
||||
// Handle onboarding completion
|
||||
const handleOnboardingExit = useCallback(async () => {
|
||||
// Refresh status from Stripe to sync the local database
|
||||
try {
|
||||
await refreshConnectStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh Connect status:', err);
|
||||
}
|
||||
setLoadingState('complete');
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
// Handle errors from the Connect component
|
||||
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
||||
console.error('Connect component load error:', loadError);
|
||||
const message = loadError.error.message || 'Failed to load payment component';
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}, [onError]);
|
||||
|
||||
// Account type display
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
default:
|
||||
return 'Connect';
|
||||
}
|
||||
};
|
||||
|
||||
// If account is already active, show status
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800">Stripe Connected</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Type:</span>
|
||||
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||
{connectAccount.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Charges:</span>
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CreditCard size={14} />
|
||||
Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Payouts:</span>
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Wallet size={14} />
|
||||
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completion state
|
||||
if (loadingState === 'complete') {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
||||
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
|
||||
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Your Stripe account has been set up. You can now accept payments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (loadingState === 'error') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">Setup Failed</h4>
|
||||
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadingState('idle');
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle state - show start button
|
||||
if (loadingState === 'idle') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
Complete the onboarding process to start accepting payments from your customers.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={initializeStripeConnect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
Start Payment Setup
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loadingState === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
|
||||
<p className="text-gray-600">Initializing payment setup...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready state - show embedded onboarding
|
||||
if (loadingState === 'ready' && stripeConnectInstance) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Fill out the information below to finish setting up your payment account.
|
||||
Your information is securely handled by Stripe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
|
||||
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
|
||||
<ConnectAccountOnboarding
|
||||
onExit={handleOnboardingExit}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
</ConnectComponentsProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ConnectOnboardingEmbed;
|
||||
180
frontend/src/components/DevQuickLogin.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
import { setCookie } from '../utils/cookies';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const testUsers: TestUser[] = [
|
||||
{
|
||||
username: 'superuser',
|
||||
password: 'test123',
|
||||
role: 'SUPERUSER',
|
||||
label: 'Platform Superuser',
|
||||
color: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_manager',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_MANAGER',
|
||||
label: 'Platform Manager',
|
||||
color: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_sales',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SALES',
|
||||
label: 'Platform Sales',
|
||||
color: 'bg-green-600 hover:bg-green-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_support',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SUPPORT',
|
||||
label: 'Platform Support',
|
||||
color: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_owner',
|
||||
password: 'test123',
|
||||
role: 'TENANT_OWNER',
|
||||
label: 'Business Owner',
|
||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_manager',
|
||||
password: 'test123',
|
||||
role: 'TENANT_MANAGER',
|
||||
label: 'Business Manager',
|
||||
color: 'bg-pink-600 hover:bg-pink-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_staff',
|
||||
password: 'test123',
|
||||
role: 'TENANT_STAFF',
|
||||
label: 'Staff Member',
|
||||
color: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
{
|
||||
username: 'customer',
|
||||
password: 'test123',
|
||||
role: 'CUSTOMER',
|
||||
label: 'Customer',
|
||||
color: 'bg-orange-600 hover:bg-orange-700',
|
||||
},
|
||||
];
|
||||
|
||||
export function DevQuickLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Only show in development
|
||||
if (import.meta.env.PROD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleQuickLogin = async (user: TestUser) => {
|
||||
setLoading(user.username);
|
||||
try {
|
||||
// Call token auth API
|
||||
const response = await apiClient.post('/api/auth-token/', {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||
setCookie('access_token', response.data.token, 7);
|
||||
|
||||
// Invalidate queries to refetch user data
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
|
||||
|
||||
// Reload page to trigger auth flow
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Quick login failed:', error);
|
||||
alert(`Failed to login as ${user.label}: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
🔓 Quick Login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white rounded-lg shadow-2xl border-2 border-gray-300 p-4 max-w-md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-gray-800 flex items-center gap-2">
|
||||
<span>🔓</span>
|
||||
<span>Quick Login (Dev Only)</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="text-gray-500 hover:text-gray-700 text-xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{testUsers.map((user) => (
|
||||
<button
|
||||
key={user.username}
|
||||
onClick={() => handleQuickLogin(user)}
|
||||
disabled={loading !== null}
|
||||
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{loading === user.username ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Logging in...
|
||||
</span>
|
||||
) : (
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{user.label}</div>
|
||||
<div className="text-xs opacity-90">{user.role}</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-500 text-center">
|
||||
Password for all: <code className="bg-gray-100 px-1 rounded">test123</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
636
frontend/src/components/DomainPurchase.tsx
Normal file
@@ -0,0 +1,636 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Globe,
|
||||
Check,
|
||||
X,
|
||||
ShoppingCart,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useDomainSearch,
|
||||
useRegisterDomain,
|
||||
useRegisteredDomains,
|
||||
type DomainAvailability,
|
||||
type RegistrantContact,
|
||||
} from '../hooks/useDomains';
|
||||
|
||||
interface DomainPurchaseProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type Step = 'search' | 'details' | 'confirm';
|
||||
|
||||
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
const [step, setStep] = useState<Step>('search');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState<DomainAvailability | null>(null);
|
||||
const [years, setYears] = useState(1);
|
||||
const [whoisPrivacy, setWhoisPrivacy] = useState(true);
|
||||
const [autoRenew, setAutoRenew] = useState(true);
|
||||
const [autoConfigureDomain, setAutoConfigureDomain] = useState(true);
|
||||
|
||||
// Contact info form state
|
||||
const [contact, setContact] = useState<RegistrantContact>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip_code: '',
|
||||
country: 'US',
|
||||
});
|
||||
|
||||
const searchMutation = useDomainSearch();
|
||||
const registerMutation = useRegisterDomain();
|
||||
const { data: registeredDomains } = useRegisteredDomains();
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
try {
|
||||
const results = await searchMutation.mutateAsync({
|
||||
query: searchQuery,
|
||||
tlds: ['.com', '.net', '.org', '.io', '.co'],
|
||||
});
|
||||
setSearchResults(results);
|
||||
} catch {
|
||||
// Error is handled by React Query
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDomain = (domain: DomainAvailability) => {
|
||||
setSelectedDomain(domain);
|
||||
setStep('details');
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedDomain) return;
|
||||
|
||||
try {
|
||||
await registerMutation.mutateAsync({
|
||||
domain: selectedDomain.domain,
|
||||
years,
|
||||
whois_privacy: whoisPrivacy,
|
||||
auto_renew: autoRenew,
|
||||
contact,
|
||||
auto_configure: autoConfigureDomain,
|
||||
});
|
||||
|
||||
// Reset and go back to search
|
||||
setStep('search');
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSelectedDomain(null);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
// Error is handled by React Query
|
||||
}
|
||||
};
|
||||
|
||||
const updateContact = (field: keyof RegistrantContact, value: string) => {
|
||||
setContact((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const isContactValid = () => {
|
||||
return (
|
||||
contact.first_name &&
|
||||
contact.last_name &&
|
||||
contact.email &&
|
||||
contact.phone &&
|
||||
contact.address &&
|
||||
contact.city &&
|
||||
contact.state &&
|
||||
contact.zip_code &&
|
||||
contact.country
|
||||
);
|
||||
};
|
||||
|
||||
const getPrice = () => {
|
||||
if (!selectedDomain) return 0;
|
||||
const basePrice = selectedDomain.premium_price || selectedDomain.price || 0;
|
||||
return basePrice * years;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
step === 'search' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
step === 'search'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">Search</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
step === 'details' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
step === 'details'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">Details</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
step === 'confirm' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
step === 'confirm'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">Confirm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Search */}
|
||||
{step === 'search' && (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Enter domain name or keyword..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchMutation.isPending || !searchQuery.trim()}
|
||||
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{searchMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-5 w-5" />
|
||||
)}
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Search Results</h4>
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((result) => (
|
||||
<div
|
||||
key={result.domain}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
result.available
|
||||
? 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{result.available ? (
|
||||
<Check className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{result.domain}
|
||||
</span>
|
||||
{result.premium && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{result.available && (
|
||||
<>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
${(result.premium_price || result.price || 0).toFixed(2)}/yr
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSelectDomain(result)}
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 flex items-center gap-2"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Select
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!result.available && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Registered Domains */}
|
||||
{registeredDomains && registeredDomains.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Your Registered Domains
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{registeredDomains.map((domain) => (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-gray-400" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{domain.domain}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
domain.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{domain.status}
|
||||
</span>
|
||||
</div>
|
||||
{domain.expires_at && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Expires: {new Date(domain.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Details */}
|
||||
{step === 'details' && selectedDomain && (
|
||||
<div className="space-y-6">
|
||||
{/* Selected Domain */}
|
||||
<div className="p-4 bg-brand-50 dark:bg-brand-900/20 rounded-lg border border-brand-200 dark:border-brand-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-6 w-6 text-brand-600 dark:text-brand-400" />
|
||||
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedDomain.domain}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStep('search')}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Registration Period
|
||||
</label>
|
||||
<select
|
||||
value={years}
|
||||
onChange={(e) => setYears(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
{[1, 2, 3, 5, 10].map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y} {y === 1 ? 'year' : 'years'} - $
|
||||
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy & Auto-renew */}
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={whoisPrivacy}
|
||||
onChange={(e) => setWhoisPrivacy(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
WHOIS Privacy Protection
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Hide your personal information from public WHOIS lookups
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRenew}
|
||||
onChange={(e) => setAutoRenew(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">Auto-Renewal</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically renew this domain before it expires
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoConfigureDomain}
|
||||
onChange={(e) => setAutoConfigureDomain(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
Auto-configure as Custom Domain
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically set up this domain for your business
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Registrant Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.first_name}
|
||||
onChange={(e) => updateContact('first_name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.last_name}
|
||||
onChange={(e) => updateContact('last_name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={contact.email}
|
||||
onChange={(e) => updateContact('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={contact.phone}
|
||||
onChange={(e) => updateContact('phone', e.target.value)}
|
||||
placeholder="+1.5551234567"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.address}
|
||||
onChange={(e) => updateContact('address', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
City *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.city}
|
||||
onChange={(e) => updateContact('city', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
State/Province *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.state}
|
||||
onChange={(e) => updateContact('state', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
ZIP/Postal Code *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={contact.zip_code}
|
||||
onChange={(e) => updateContact('zip_code', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Country *
|
||||
</label>
|
||||
<select
|
||||
value={contact.country}
|
||||
onChange={(e) => updateContact('country', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<button
|
||||
onClick={() => setStep('search')}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('confirm')}
|
||||
disabled={!isContactValid()}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 'confirm' && selectedDomain && (
|
||||
<div className="space-y-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Domain</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{selectedDomain.domain}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Registration Period</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{years} {years === 1 ? 'year' : 'years'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{whoisPrivacy ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{autoRenew ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Total</span>
|
||||
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
|
||||
${getPrice().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registrant Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Registrant</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.first_name} {contact.last_name}
|
||||
<br />
|
||||
{contact.email}
|
||||
<br />
|
||||
{contact.address}
|
||||
<br />
|
||||
{contact.city}, {contact.state} {contact.zip_code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{registerMutation.isError && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Registration failed. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<button
|
||||
onClick={() => setStep('details')}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
disabled={registerMutation.isPending}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
)}
|
||||
Complete Purchase
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainPurchase;
|
||||
111
frontend/src/components/LanguageSelector.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Language Selector Component
|
||||
* Dropdown for selecting the application language
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe, Check, ChevronDown } from 'lucide-react';
|
||||
import { supportedLanguages, SupportedLanguage } from '../i18n';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
variant?: 'dropdown' | 'inline';
|
||||
showFlag?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
variant = 'dropdown',
|
||||
showFlag = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLanguage = supportedLanguages.find(
|
||||
(lang) => lang.code === i18n.language
|
||||
) || supportedLanguages[0];
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLanguageChange = (code: SupportedLanguage) => {
|
||||
i18n.changeLanguage(code);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
i18n.language === lang.code
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{showFlag && <span className="mr-1.5">{lang.flag}</span>}
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{showFlag && <span>{currentLanguage.flag}</span>}
|
||||
<span className="hidden sm:inline">{currentLanguage.name}</span>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1 animate-in fade-in slide-in-from-top-2">
|
||||
<ul role="listbox" aria-label="Select language">
|
||||
{supportedLanguages.map((lang) => (
|
||||
<li key={lang.code}>
|
||||
<button
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${
|
||||
i18n.language === lang.code
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={i18n.language === lang.code}
|
||||
>
|
||||
<span className="text-lg">{lang.flag}</span>
|
||||
<span className="flex-1">{lang.name}</span>
|
||||
{i18n.language === lang.code && (
|
||||
<Check className="w-4 h-4 text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
40
frontend/src/components/MasqueradeBanner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Eye, XCircle } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
|
||||
interface MasqueradeBannerProps {
|
||||
effectiveUser: User;
|
||||
originalUser: User;
|
||||
previousUser: User | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
|
||||
|
||||
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
|
||||
|
||||
return (
|
||||
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 bg-white/20 rounded-full animate-pulse">
|
||||
<Eye size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
|
||||
<span className="opacity-75 mx-2 text-xs">|</span>
|
||||
Logged in as {originalUser.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="flex items-center gap-2 px-3 py-1 text-xs font-bold uppercase bg-white text-orange-600 rounded hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
<XCircle size={14} />
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasqueradeBanner;
|
||||
156
frontend/src/components/OAuthButtons.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* OAuth Buttons Component
|
||||
* Displays OAuth provider buttons with icons and brand colors
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useInitiateOAuth, useOAuthProviders } from '../hooks/useOAuth';
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onSuccess?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Provider configurations with colors and icons
|
||||
const providerConfig: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
bgColor: string;
|
||||
hoverColor: string;
|
||||
textColor: string;
|
||||
icon: string;
|
||||
}
|
||||
> = {
|
||||
google: {
|
||||
name: 'Google',
|
||||
bgColor: 'bg-white',
|
||||
hoverColor: 'hover:bg-gray-50',
|
||||
textColor: 'text-gray-900',
|
||||
icon: 'G',
|
||||
},
|
||||
apple: {
|
||||
name: 'Apple',
|
||||
bgColor: 'bg-black',
|
||||
hoverColor: 'hover:bg-gray-900',
|
||||
textColor: 'text-white',
|
||||
icon: '',
|
||||
},
|
||||
facebook: {
|
||||
name: 'Facebook',
|
||||
bgColor: 'bg-[#1877F2]',
|
||||
hoverColor: 'hover:bg-[#166FE5]',
|
||||
textColor: 'text-white',
|
||||
icon: 'f',
|
||||
},
|
||||
linkedin: {
|
||||
name: 'LinkedIn',
|
||||
bgColor: 'bg-[#0A66C2]',
|
||||
hoverColor: 'hover:bg-[#095196]',
|
||||
textColor: 'text-white',
|
||||
icon: 'in',
|
||||
},
|
||||
microsoft: {
|
||||
name: 'Microsoft',
|
||||
bgColor: 'bg-[#00A4EF]',
|
||||
hoverColor: 'hover:bg-[#0078D4]',
|
||||
textColor: 'text-white',
|
||||
icon: 'M',
|
||||
},
|
||||
x: {
|
||||
name: 'X',
|
||||
bgColor: 'bg-black',
|
||||
hoverColor: 'hover:bg-gray-900',
|
||||
textColor: 'text-white',
|
||||
icon: 'X',
|
||||
},
|
||||
twitch: {
|
||||
name: 'Twitch',
|
||||
bgColor: 'bg-[#9146FF]',
|
||||
hoverColor: 'hover:bg-[#7D3ACE]',
|
||||
textColor: 'text-white',
|
||||
icon: 'T',
|
||||
},
|
||||
};
|
||||
|
||||
const OAuthButtons: React.FC<OAuthButtonsProps> = ({ onSuccess, disabled = false }) => {
|
||||
const { data: providers, isLoading } = useOAuthProviders();
|
||||
const initiateMutation = useInitiateOAuth();
|
||||
|
||||
const handleOAuthClick = (providerId: string) => {
|
||||
if (disabled || initiateMutation.isPending) return;
|
||||
|
||||
initiateMutation.mutate(providerId, {
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('OAuth initiation error:', error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providers || providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => {
|
||||
const config = providerConfig[provider.name] || {
|
||||
name: provider.display_name,
|
||||
bgColor: 'bg-gray-600',
|
||||
hoverColor: 'hover:bg-gray-700',
|
||||
textColor: 'text-white',
|
||||
icon: provider.display_name.charAt(0).toUpperCase(),
|
||||
};
|
||||
|
||||
const isCurrentlyLoading =
|
||||
initiateMutation.isPending && initiateMutation.variables === provider.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.name}
|
||||
type="button"
|
||||
onClick={() => handleOAuthClick(provider.name)}
|
||||
disabled={disabled || initiateMutation.isPending}
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-3 py-3 px-4
|
||||
border rounded-lg shadow-sm text-sm font-medium
|
||||
transition-all duration-200 ease-in-out transform active:scale-[0.98]
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${config.bgColor} ${config.hoverColor} ${config.textColor}
|
||||
${provider.name === 'google' ? 'border-gray-300 dark:border-gray-700' : 'border-transparent'}
|
||||
`}
|
||||
>
|
||||
{isCurrentlyLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Connecting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center justify-center w-5 h-5 font-bold text-sm">
|
||||
{config.icon}
|
||||
</span>
|
||||
<span>Continue with {config.name}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthButtons;
|
||||
329
frontend/src/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Onboarding Wizard Component
|
||||
* Multi-step wizard for paid-tier businesses to complete post-signup setup
|
||||
* Step 1: Welcome/Overview
|
||||
* Step 2: Stripe Connect setup (embedded)
|
||||
* Step 3: Completion
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CheckCircle,
|
||||
CreditCard,
|
||||
Rocket,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
X,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Business } from '../types';
|
||||
import { usePaymentConfig } from '../hooks/usePayments';
|
||||
import { useUpdateBusiness } from '../hooks/useBusiness';
|
||||
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
business: Business;
|
||||
onComplete: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type OnboardingStep = 'welcome' | 'stripe' | 'complete';
|
||||
|
||||
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
business,
|
||||
onComplete,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [currentStep, setCurrentStep] = useState<OnboardingStep>('welcome');
|
||||
|
||||
const { data: paymentConfig, isLoading: configLoading, refetch: refetchConfig } = usePaymentConfig();
|
||||
const updateBusinessMutation = useUpdateBusiness();
|
||||
|
||||
// Check if Stripe Connect is complete
|
||||
const isStripeConnected = paymentConfig?.connect_account?.status === 'active' &&
|
||||
paymentConfig?.connect_account?.charges_enabled;
|
||||
|
||||
// Handle return from Stripe Connect (for fallback redirect flow)
|
||||
useEffect(() => {
|
||||
const connectStatus = searchParams.get('connect');
|
||||
if (connectStatus === 'complete' || connectStatus === 'refresh') {
|
||||
// User returned from Stripe, refresh the config
|
||||
refetchConfig();
|
||||
// Clear the search params
|
||||
setSearchParams({});
|
||||
// Show stripe step to verify completion
|
||||
setCurrentStep('stripe');
|
||||
}
|
||||
}, [searchParams, refetchConfig, setSearchParams]);
|
||||
|
||||
// Auto-advance to complete step when Stripe is connected
|
||||
useEffect(() => {
|
||||
if (isStripeConnected && currentStep === 'stripe') {
|
||||
setCurrentStep('complete');
|
||||
}
|
||||
}, [isStripeConnected, currentStep]);
|
||||
|
||||
// Handle embedded onboarding completion
|
||||
const handleEmbeddedOnboardingComplete = () => {
|
||||
refetchConfig();
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
// Handle embedded onboarding error
|
||||
const handleEmbeddedOnboardingError = (error: string) => {
|
||||
console.error('Embedded onboarding error:', error);
|
||||
};
|
||||
|
||||
const handleCompleteOnboarding = async () => {
|
||||
try {
|
||||
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to complete onboarding:', err);
|
||||
onComplete(); // Still call onComplete even if the update fails
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
try {
|
||||
await updateBusinessMutation.mutateAsync({ initialSetupComplete: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to skip onboarding:', err);
|
||||
}
|
||||
if (onSkip) {
|
||||
onSkip();
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ key: 'welcome', label: t('onboarding.steps.welcome') },
|
||||
{ key: 'stripe', label: t('onboarding.steps.payments') },
|
||||
{ key: 'complete', label: t('onboarding.steps.complete') },
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||
|
||||
// Step indicator component
|
||||
const StepIndicator = () => (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.key}>
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
|
||||
index < currentStepIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: index === currentStepIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{index < currentStepIndex ? (
|
||||
<CheckCircle size={16} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 ${
|
||||
index < currentStepIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Welcome step
|
||||
const WelcomeStep = () => (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center mb-6">
|
||||
<Sparkles className="text-white" size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('onboarding.welcome.title', { businessName: business.name })}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
||||
{t('onboarding.welcome.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6 max-w-md mx-auto">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3 text-left">
|
||||
{t('onboarding.welcome.whatsIncluded')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-left">
|
||||
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||
<CreditCard size={18} className="text-blue-500 shrink-0" />
|
||||
<span>{t('onboarding.welcome.connectStripe')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||
<CheckCircle size={18} className="text-green-500 shrink-0" />
|
||||
<span>{t('onboarding.welcome.automaticPayouts')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
|
||||
<CheckCircle size={18} className="text-green-500 shrink-0" />
|
||||
<span>{t('onboarding.welcome.pciCompliance')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 max-w-xs mx-auto">
|
||||
<button
|
||||
onClick={() => setCurrentStep('stripe')}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t('onboarding.welcome.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{t('onboarding.welcome.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Stripe Connect step - uses embedded onboarding
|
||||
const StripeStep = () => (
|
||||
<div>
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-[#635BFF] rounded-full flex items-center justify-center mb-6">
|
||||
<CreditCard className="text-white" size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('onboarding.stripe.title')}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t('onboarding.stripe.subtitle', { plan: business.plan })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{configLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="animate-spin text-gray-400" size={24} />
|
||||
<span className="text-gray-500">{t('onboarding.stripe.checkingStatus')}</span>
|
||||
</div>
|
||||
) : isStripeConnected ? (
|
||||
<div className="space-y-4 max-w-md mx-auto">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="text-green-600 dark:text-green-400" size={24} />
|
||||
<div className="text-left">
|
||||
<h4 className="font-medium text-green-800 dark:text-green-300">
|
||||
{t('onboarding.stripe.connected.title')}
|
||||
</h4>
|
||||
<p className="text-sm text-green-700 dark:text-green-400">
|
||||
{t('onboarding.stripe.connected.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentStep('complete')}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t('onboarding.stripe.continue')}
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md mx-auto">
|
||||
<ConnectOnboardingEmbed
|
||||
connectAccount={paymentConfig?.connect_account || null}
|
||||
tier={business.plan}
|
||||
onComplete={handleEmbeddedOnboardingComplete}
|
||||
onError={handleEmbeddedOnboardingError}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full mt-4 px-6 py-2 text-gray-500 dark:text-gray-400 text-sm hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{t('onboarding.stripe.doLater')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Complete step
|
||||
const CompleteStep = () => (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-green-400 to-green-600 rounded-full flex items-center justify-center mb-6">
|
||||
<Rocket className="text-white" size={32} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('onboarding.complete.title')}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 max-w-md mx-auto">
|
||||
{t('onboarding.complete.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6 max-w-md mx-auto">
|
||||
<ul className="space-y-2 text-left">
|
||||
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle size={16} className="shrink-0" />
|
||||
<span>{t('onboarding.complete.checklist.accountCreated')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle size={16} className="shrink-0" />
|
||||
<span>{t('onboarding.complete.checklist.stripeConfigured')}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<CheckCircle size={16} className="shrink-0" />
|
||||
<span>{t('onboarding.complete.checklist.readyForPayments')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCompleteOnboarding}
|
||||
disabled={updateBusinessMutation.isPending}
|
||||
className="px-8 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{updateBusinessMutation.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
t('onboarding.complete.goToDashboard')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-auto">
|
||||
{/* Header with close button */}
|
||||
<div className="flex justify-end p-4 pb-0">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t('onboarding.skipForNow')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-8 pb-8">
|
||||
<StepIndicator />
|
||||
|
||||
{currentStep === 'welcome' && <WelcomeStep />}
|
||||
{currentStep === 'stripe' && <StripeStep />}
|
||||
{currentStep === 'complete' && <CompleteStep />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingWizard;
|
||||
220
frontend/src/components/PaymentSettingsSection.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Payment Settings Section Component
|
||||
* Unified payment configuration UI that shows the appropriate setup
|
||||
* based on the business tier (API keys for Free, Connect for Paid)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CreditCard,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Business } from '../types';
|
||||
import { usePaymentConfig } from '../hooks/usePayments';
|
||||
import StripeApiKeysForm from './StripeApiKeysForm';
|
||||
import ConnectOnboardingEmbed from './ConnectOnboardingEmbed';
|
||||
|
||||
interface PaymentSettingsSectionProps {
|
||||
business: Business;
|
||||
}
|
||||
|
||||
type PaymentModeType = 'direct_api' | 'connect' | 'none';
|
||||
|
||||
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
|
||||
const { data: config, isLoading, error, refetch } = usePaymentConfig();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="animate-spin text-gray-400" size={24} />
|
||||
<span className="text-gray-600">Loading payment configuration...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle size={24} />
|
||||
<span>Failed to load payment configuration</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const paymentMode = (config?.payment_mode || 'none') as PaymentModeType;
|
||||
const canAcceptPayments = config?.can_accept_payments || false;
|
||||
const tier = config?.tier || business.plan || 'Free';
|
||||
const isFreeTier = tier === 'Free';
|
||||
|
||||
// Determine Stripe environment (test vs live) from API keys
|
||||
const getStripeEnvironment = (): 'test' | 'live' | null => {
|
||||
const maskedKey = config?.api_keys?.publishable_key_masked;
|
||||
if (!maskedKey) return null;
|
||||
if (maskedKey.startsWith('pk_test_')) return 'test';
|
||||
if (maskedKey.startsWith('pk_live_')) return 'live';
|
||||
return null;
|
||||
};
|
||||
const stripeEnvironment = getStripeEnvironment();
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge = () => {
|
||||
if (canAcceptPayments) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
||||
<CheckCircle size={12} />
|
||||
Ready
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">
|
||||
<AlertCircle size={12} />
|
||||
Setup Required
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Mode description
|
||||
const getModeDescription = () => {
|
||||
if (isFreeTier) {
|
||||
return 'Free tier businesses use their own Stripe API keys for payment processing. No platform fees apply.';
|
||||
}
|
||||
return `${tier} tier businesses use Stripe Connect for payment processing with platform-managed payments.`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<CreditCard className="text-purple-600" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2>
|
||||
<p className="text-sm text-gray-500">{getModeDescription()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test/Live Mode Banner */}
|
||||
{stripeEnvironment && config?.api_keys?.status === 'active' && (
|
||||
<div
|
||||
className={`px-6 py-3 flex items-center gap-3 ${
|
||||
stripeEnvironment === 'test'
|
||||
? 'bg-amber-50 border-b border-amber-200'
|
||||
: 'bg-green-50 border-b border-green-200'
|
||||
}`}
|
||||
>
|
||||
{stripeEnvironment === 'test' ? (
|
||||
<>
|
||||
<div className="p-2 bg-amber-100 rounded-full">
|
||||
<FlaskConical className="text-amber-600" size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-amber-800">Test Mode</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
Payments are simulated. No real money will be charged.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://dashboard.stripe.com/test/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-amber-700 hover:text-amber-800 underline"
|
||||
>
|
||||
Get Live Keys
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-2 bg-green-100 rounded-full">
|
||||
<Zap className="text-green-600" size={20} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-green-800">Live Mode</p>
|
||||
<p className="text-sm text-green-700">
|
||||
Payments are real. Customers will be charged.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Tier info banner */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600">Current Plan:</span>
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${
|
||||
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' :
|
||||
tier === 'Business' ? 'bg-blue-100 text-blue-800' :
|
||||
tier === 'Professional' ? 'bg-green-100 text-green-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{tier}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Payment Mode:{' '}
|
||||
<span className="font-medium text-gray-900">
|
||||
{paymentMode === 'direct_api' ? 'Direct API Keys' :
|
||||
paymentMode === 'connect' ? 'Stripe Connect' :
|
||||
'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier-specific content */}
|
||||
{isFreeTier ? (
|
||||
<StripeApiKeysForm
|
||||
apiKeys={config?.api_keys || null}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<ConnectOnboardingEmbed
|
||||
connectAccount={config?.connect_account || null}
|
||||
tier={tier}
|
||||
onComplete={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upgrade notice for free tier with deprecated keys */}
|
||||
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-blue-800 mb-1">
|
||||
Upgraded to a Paid Plan?
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
If you've recently upgraded, your API keys have been deprecated.
|
||||
Please contact support to complete your Stripe Connect setup.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSettingsSection;
|
||||
85
frontend/src/components/PlatformSidebar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
interface PlatformSidebarProps {
|
||||
user: User;
|
||||
isCollapsed: boolean;
|
||||
toggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, toggleCollapse }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const getNavClass = (path: string) => {
|
||||
const isActive = location.pathname === path || (path !== '/' && location.pathname.startsWith(path));
|
||||
const baseClasses = `flex items-center gap-3 py-2 text-sm font-medium rounded-md transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-3';
|
||||
const activeClasses = 'bg-gray-700 text-white';
|
||||
const inactiveClasses = 'text-gray-400 hover:text-white hover:bg-gray-800';
|
||||
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
|
||||
};
|
||||
|
||||
const isSuperuser = user.role === 'superuser';
|
||||
const isManager = user.role === 'platform_manager';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-gray-900 text-white shrink-0 border-r border-gray-800 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}>
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-6 border-b border-gray-800 ${isCollapsed ? 'justify-center' : ''} hover:bg-gray-800 transition-colors focus:outline-none`}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<SmoothScheduleLogo className="w-10 h-10 shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold text-sm tracking-wide uppercase text-gray-100 truncate">Smooth Schedule</h1>
|
||||
<p className="text-xs text-gray-500 capitalize truncate">{user.role.replace('_', ' ')}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Ops' : 'Operations'}</p>
|
||||
{(isSuperuser || isManager) && (
|
||||
<Link to="/platform/dashboard" className={getNavClass('/platform/dashboard')} title={t('nav.platformDashboard')}>
|
||||
<LayoutDashboard size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/platform/businesses" className={getNavClass("/platform/businesses")} title={t('nav.businesses')}>
|
||||
<Building2 size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.businesses')}</span>}
|
||||
</Link>
|
||||
<Link to="/platform/users" className={getNavClass('/platform/users')} title={t('nav.users')}>
|
||||
<Users size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.users')}</span>}
|
||||
</Link>
|
||||
<Link to="/platform/support" className={getNavClass('/platform/support')} title={t('nav.support')}>
|
||||
<MessageSquare size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.support')}</span>}
|
||||
</Link>
|
||||
|
||||
{isSuperuser && (
|
||||
<>
|
||||
<p className={`text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 mt-8 ${isCollapsed ? 'text-center' : 'px-3'}`}>{isCollapsed ? 'Sys' : 'System'}</p>
|
||||
<Link to="/platform/staff" className={getNavClass('/platform/staff')} title={t('nav.staff')}>
|
||||
<Shield size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||
</Link>
|
||||
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
|
||||
<Settings size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformSidebar;
|
||||
26
frontend/src/components/Portal.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal component that renders children directly into document.body.
|
||||
* This bypasses any parent stacking contexts created by CSS transforms,
|
||||
* ensuring modals with fixed positioning cover the entire viewport.
|
||||
*/
|
||||
const Portal: React.FC<PortalProps> = ({ children }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(children, document.body);
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
252
frontend/src/components/QuickAddAppointment.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarPlus, Clock, User, Briefcase, MapPin, FileText, Loader2, Check } from 'lucide-react';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
import { useCreateAppointment } from '../hooks/useAppointments';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface QuickAddAppointmentProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const QuickAddAppointment: React.FC<QuickAddAppointmentProps> = ({ onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: services } = useServices();
|
||||
const { data: resources } = useResources();
|
||||
const { data: customers } = useCustomers();
|
||||
const createAppointment = useCreateAppointment();
|
||||
|
||||
const [customerId, setCustomerId] = useState('');
|
||||
const [serviceId, setServiceId] = useState('');
|
||||
const [resourceId, setResourceId] = useState('');
|
||||
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [time, setTime] = useState('09:00');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// Get selected service to auto-fill duration
|
||||
const selectedService = useMemo(() => {
|
||||
return services?.find(s => s.id === serviceId);
|
||||
}, [services, serviceId]);
|
||||
|
||||
// Generate time slots (every 15 minutes from 6am to 10pm)
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots = [];
|
||||
for (let hour = 6; hour <= 22; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
const h = hour.toString().padStart(2, '0');
|
||||
const m = minute.toString().padStart(2, '0');
|
||||
slots.push(`${h}:${m}`);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!serviceId || !date || !time) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const startTime = new Date(date);
|
||||
startTime.setHours(hours, minutes, 0, 0);
|
||||
|
||||
try {
|
||||
await createAppointment.mutateAsync({
|
||||
customerId: customerId || undefined,
|
||||
customerName: customerId ? (customers?.find(c => c.id === customerId)?.name || '') : 'Walk-in',
|
||||
serviceId,
|
||||
resourceId: resourceId || null,
|
||||
startTime,
|
||||
durationMinutes: selectedService?.durationMinutes || 60,
|
||||
status: 'Scheduled',
|
||||
notes,
|
||||
});
|
||||
|
||||
// Show success state
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => setShowSuccess(false), 2000);
|
||||
|
||||
// Reset form
|
||||
setCustomerId('');
|
||||
setServiceId('');
|
||||
setResourceId('');
|
||||
setNotes('');
|
||||
setTime('09:00');
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to create appointment:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCustomers = customers?.filter(c => c.status === 'Active') || [];
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<CalendarPlus className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('dashboard.quickAddAppointment', 'Quick Add Appointment')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Customer Select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<User className="inline h-4 w-4 mr-1" />
|
||||
{t('appointments.customer', 'Customer')}
|
||||
</label>
|
||||
<select
|
||||
value={customerId}
|
||||
onChange={(e) => setCustomerId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="">{t('appointments.walkIn', 'Walk-in / No customer')}</option>
|
||||
{activeCustomers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name} {customer.email && `(${customer.email})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Service Select */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<Briefcase className="inline h-4 w-4 mr-1" />
|
||||
{t('appointments.service', 'Service')} *
|
||||
</label>
|
||||
<select
|
||||
value={serviceId}
|
||||
onChange={(e) => setServiceId(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="">{t('appointments.selectService', 'Select service...')}</option>
|
||||
{services?.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name} ({service.durationMinutes} min - ${service.price})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Resource Select (Optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<MapPin className="inline h-4 w-4 mr-1" />
|
||||
{t('appointments.resource', 'Resource')}
|
||||
</label>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="">{t('appointments.unassigned', 'Unassigned')}</option>
|
||||
{resources?.map((resource) => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date and Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('appointments.date', 'Date')} *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<Clock className="inline h-4 w-4 mr-1" />
|
||||
{t('appointments.time', 'Time')} *
|
||||
</label>
|
||||
<select
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{timeSlots.map((slot) => (
|
||||
<option key={slot} value={slot}>
|
||||
{slot}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Display */}
|
||||
{selectedService && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('appointments.duration', 'Duration')}: {selectedService.durationMinutes} {t('common.minutes', 'minutes')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<FileText className="inline h-4 w-4 mr-1" />
|
||||
{t('appointments.notes', 'Notes')}
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('appointments.notesPlaceholder', 'Optional notes...')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createAppointment.isPending || !serviceId}
|
||||
className={`w-full py-2.5 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
showSuccess
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{createAppointment.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('common.creating', 'Creating...')}
|
||||
</>
|
||||
) : showSuccess ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t('common.created', 'Created!')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t('appointments.addAppointment', 'Add Appointment')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddAppointment;
|
||||
729
frontend/src/components/ResourceCalendar.tsx
Normal file
@@ -0,0 +1,729 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
|
||||
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
|
||||
import { Appointment } from '../types';
|
||||
import Portal from './Portal';
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
// Format duration as hours and minutes when >= 60 min
|
||||
const formatDuration = (minutes: number): string => {
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${minutes} min`;
|
||||
};
|
||||
|
||||
// Constants for timeline rendering
|
||||
const PIXELS_PER_HOUR = 64;
|
||||
const PIXELS_PER_MINUTE = PIXELS_PER_HOUR / 60;
|
||||
|
||||
interface ResourceCalendarProps {
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const timeLabelsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drag state
|
||||
const [dragState, setDragState] = useState<{
|
||||
appointmentId: string;
|
||||
startY: number;
|
||||
originalStartTime: Date;
|
||||
originalDuration: number;
|
||||
} | null>(null);
|
||||
const [dragPreview, setDragPreview] = useState<Date | null>(null);
|
||||
|
||||
// Resize state
|
||||
const [resizeState, setResizeState] = useState<{
|
||||
appointmentId: string;
|
||||
direction: 'top' | 'bottom';
|
||||
startY: number;
|
||||
originalStartTime: Date;
|
||||
originalDuration: number;
|
||||
} | null>(null);
|
||||
const [resizePreview, setResizePreview] = useState<{ startTime: Date; duration: number } | null>(null);
|
||||
|
||||
const updateMutation = useUpdateAppointment();
|
||||
|
||||
// Auto-scroll to current time or 8 AM when switching to day/week view
|
||||
useEffect(() => {
|
||||
if ((viewMode === 'day' || viewMode === 'week') && timelineRef.current) {
|
||||
const now = new Date();
|
||||
const scrollToHour = isToday(currentDate)
|
||||
? Math.max(now.getHours() - 1, 0) // Scroll to an hour before current time
|
||||
: 8; // Default to 8 AM for other days
|
||||
timelineRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
|
||||
// Sync time labels scroll
|
||||
if (timeLabelsRef.current) {
|
||||
timeLabelsRef.current.scrollTop = scrollToHour * PIXELS_PER_HOUR;
|
||||
}
|
||||
}
|
||||
}, [viewMode, currentDate]);
|
||||
|
||||
// Sync scroll between timeline and time labels (for week view)
|
||||
useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
const timeLabels = timeLabelsRef.current;
|
||||
if (!timeline || !timeLabels) return;
|
||||
|
||||
const handleTimelineScroll = () => {
|
||||
if (timeLabels) {
|
||||
timeLabels.scrollTop = timeline.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
timeline.addEventListener('scroll', handleTimelineScroll);
|
||||
return () => timeline.removeEventListener('scroll', handleTimelineScroll);
|
||||
}, [viewMode]);
|
||||
|
||||
// Helper to get Monday of the week containing the given date
|
||||
const getMonday = (date: Date) => {
|
||||
return startOfWeek(date, { weekStartsOn: 1 }); // 1 = Monday
|
||||
};
|
||||
|
||||
// Helper to get Friday of the week (4 days after Monday)
|
||||
const getFriday = (date: Date) => {
|
||||
return addDays(getMonday(date), 4);
|
||||
};
|
||||
|
||||
// Calculate date range based on view mode
|
||||
const dateRange = useMemo(() => {
|
||||
switch (viewMode) {
|
||||
case 'day':
|
||||
return { startDate: startOfDay(currentDate), endDate: addDays(startOfDay(currentDate), 1) };
|
||||
case 'week':
|
||||
// Full week (Monday to Sunday)
|
||||
return { startDate: getMonday(currentDate), endDate: addDays(getMonday(currentDate), 7) };
|
||||
case 'month':
|
||||
return { startDate: startOfMonth(currentDate), endDate: addDays(endOfMonth(currentDate), 1) };
|
||||
}
|
||||
}, [viewMode, currentDate]);
|
||||
|
||||
// Fetch appointments for this resource within the date range
|
||||
const { data: allAppointments = [], isLoading } = useAppointments({
|
||||
resource: resourceId,
|
||||
...dateRange
|
||||
});
|
||||
|
||||
// Filter appointments for this specific resource
|
||||
const appointments = useMemo(() => {
|
||||
const resourceIdStr = String(resourceId);
|
||||
return allAppointments.filter(apt => apt.resourceId === resourceIdStr);
|
||||
}, [allAppointments, resourceId]);
|
||||
|
||||
const navigatePrevious = () => {
|
||||
switch (viewMode) {
|
||||
case 'day':
|
||||
setCurrentDate(addDays(currentDate, -1));
|
||||
break;
|
||||
case 'week':
|
||||
setCurrentDate(addWeeks(currentDate, -1));
|
||||
break;
|
||||
case 'month':
|
||||
setCurrentDate(addMonths(currentDate, -1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const navigateNext = () => {
|
||||
switch (viewMode) {
|
||||
case 'day':
|
||||
setCurrentDate(addDays(currentDate, 1));
|
||||
break;
|
||||
case 'week':
|
||||
setCurrentDate(addWeeks(currentDate, 1));
|
||||
break;
|
||||
case 'month':
|
||||
setCurrentDate(addMonths(currentDate, 1));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (viewMode) {
|
||||
case 'day':
|
||||
return format(currentDate, 'EEEE, MMMM d, yyyy');
|
||||
case 'week':
|
||||
const weekStart = getMonday(currentDate);
|
||||
const weekEnd = addDays(weekStart, 6); // Sunday
|
||||
return `${format(weekStart, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`;
|
||||
case 'month':
|
||||
return format(currentDate, 'MMMM yyyy');
|
||||
}
|
||||
};
|
||||
|
||||
// Get appointments for a specific day
|
||||
const getAppointmentsForDay = (day: Date) => {
|
||||
return appointments.filter(apt => isSameDay(new Date(apt.startTime), day));
|
||||
};
|
||||
|
||||
// Convert Y position to time
|
||||
const yToTime = (y: number, baseDate: Date): Date => {
|
||||
const minutes = Math.round((y / PIXELS_PER_MINUTE) / 15) * 15; // Snap to 15 min
|
||||
const result = new Date(baseDate);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
result.setMinutes(minutes);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = (e: React.MouseEvent, apt: Appointment) => {
|
||||
e.preventDefault();
|
||||
const rect = timelineRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setDragState({
|
||||
appointmentId: apt.id,
|
||||
startY: e.clientY,
|
||||
originalStartTime: new Date(apt.startTime),
|
||||
originalDuration: apt.durationMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle resize start
|
||||
const handleResizeStart = (e: React.MouseEvent, apt: Appointment, direction: 'top' | 'bottom') => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeState({
|
||||
appointmentId: apt.id,
|
||||
direction,
|
||||
startY: e.clientY,
|
||||
originalStartTime: new Date(apt.startTime),
|
||||
originalDuration: apt.durationMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
// Mouse move handler for drag and resize
|
||||
useEffect(() => {
|
||||
if (!dragState && !resizeState) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (dragState) {
|
||||
const deltaY = e.clientY - dragState.startY;
|
||||
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
|
||||
const newStartTime = new Date(dragState.originalStartTime.getTime() + deltaMinutes * 60000);
|
||||
|
||||
// Keep within same day
|
||||
const dayStart = startOfDay(dragState.originalStartTime);
|
||||
const dayEnd = endOfDay(dragState.originalStartTime);
|
||||
if (newStartTime >= dayStart && newStartTime <= dayEnd) {
|
||||
setDragPreview(newStartTime);
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeState) {
|
||||
const deltaY = e.clientY - resizeState.startY;
|
||||
const deltaMinutes = Math.round((deltaY / PIXELS_PER_MINUTE) / 15) * 15;
|
||||
|
||||
if (resizeState.direction === 'bottom') {
|
||||
// Resize from bottom - change duration
|
||||
const newDuration = Math.max(15, resizeState.originalDuration + deltaMinutes);
|
||||
setResizePreview({
|
||||
startTime: resizeState.originalStartTime,
|
||||
duration: newDuration,
|
||||
});
|
||||
} else {
|
||||
// Resize from top - change start time and duration
|
||||
const newStartTime = new Date(resizeState.originalStartTime.getTime() + deltaMinutes * 60000);
|
||||
const newDuration = Math.max(15, resizeState.originalDuration - deltaMinutes);
|
||||
|
||||
// Keep within same day
|
||||
const dayStart = startOfDay(resizeState.originalStartTime);
|
||||
if (newStartTime >= dayStart) {
|
||||
setResizePreview({
|
||||
startTime: newStartTime,
|
||||
duration: newDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState && dragPreview) {
|
||||
updateMutation.mutate({
|
||||
id: dragState.appointmentId,
|
||||
updates: {
|
||||
startTime: dragPreview,
|
||||
durationMinutes: dragState.originalDuration, // Preserve duration when dragging
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (resizeState && resizePreview) {
|
||||
updateMutation.mutate({
|
||||
id: resizeState.appointmentId,
|
||||
updates: {
|
||||
startTime: resizePreview.startTime,
|
||||
durationMinutes: resizePreview.duration,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setDragState(null);
|
||||
setDragPreview(null);
|
||||
setResizeState(null);
|
||||
setResizePreview(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragState, dragPreview, resizeState, resizePreview, updateMutation]);
|
||||
|
||||
// Calculate lanes for overlapping appointments
|
||||
const calculateLanes = (appts: Appointment[]): Map<string, { lane: number; totalLanes: number }> => {
|
||||
const laneMap = new Map<string, { lane: number; totalLanes: number }>();
|
||||
if (appts.length === 0) return laneMap;
|
||||
|
||||
// Sort by start time
|
||||
const sorted = [...appts].sort((a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
|
||||
// Get end time for an appointment
|
||||
const getEndTime = (apt: Appointment) => {
|
||||
return new Date(apt.startTime).getTime() + apt.durationMinutes * 60000;
|
||||
};
|
||||
|
||||
// Find overlapping groups
|
||||
const groups: Appointment[][] = [];
|
||||
let currentGroup: Appointment[] = [];
|
||||
let groupEndTime = 0;
|
||||
|
||||
for (const apt of sorted) {
|
||||
const aptStart = new Date(apt.startTime).getTime();
|
||||
const aptEnd = getEndTime(apt);
|
||||
|
||||
if (currentGroup.length === 0 || aptStart < groupEndTime) {
|
||||
// Overlaps with current group
|
||||
currentGroup.push(apt);
|
||||
groupEndTime = Math.max(groupEndTime, aptEnd);
|
||||
} else {
|
||||
// Start new group
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = [apt];
|
||||
groupEndTime = aptEnd;
|
||||
}
|
||||
}
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
// Assign lanes within each group
|
||||
for (const group of groups) {
|
||||
const totalLanes = group.length;
|
||||
// Sort by start time within group
|
||||
group.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
|
||||
group.forEach((apt, index) => {
|
||||
laneMap.set(apt.id, { lane: index, totalLanes });
|
||||
});
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
};
|
||||
|
||||
const renderDayView = () => {
|
||||
const dayStart = startOfDay(currentDate);
|
||||
const hours = eachHourOfInterval({
|
||||
start: dayStart,
|
||||
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
|
||||
});
|
||||
|
||||
const dayAppointments = getAppointmentsForDay(currentDate);
|
||||
const laneAssignments = calculateLanes(dayAppointments);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto min-h-0" ref={timelineRef}>
|
||||
<div className="relative ml-16" style={{ height: hours.length * PIXELS_PER_HOUR }}>
|
||||
{/* Hour grid lines */}
|
||||
{hours.map((hour) => (
|
||||
<div key={hour.toISOString()} className="border-b border-gray-200 dark:border-gray-700 relative" style={{ height: PIXELS_PER_HOUR }}>
|
||||
<div className="absolute -left-16 top-0 w-14 text-xs text-gray-500 dark:text-gray-400 pr-2 text-right">
|
||||
{format(hour, 'h a')}
|
||||
</div>
|
||||
{/* Half-hour line */}
|
||||
<div className="absolute left-0 right-0 top-1/2 border-t border-dashed border-gray-100 dark:border-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render appointments */}
|
||||
{dayAppointments.map((apt) => {
|
||||
const isDragging = dragState?.appointmentId === apt.id;
|
||||
const isResizing = resizeState?.appointmentId === apt.id;
|
||||
|
||||
// Use preview values if dragging/resizing this appointment
|
||||
let displayStartTime = new Date(apt.startTime);
|
||||
let displayDuration = apt.durationMinutes;
|
||||
|
||||
if (isDragging && dragPreview) {
|
||||
displayStartTime = dragPreview;
|
||||
}
|
||||
if (isResizing && resizePreview) {
|
||||
displayStartTime = resizePreview.startTime;
|
||||
displayDuration = resizePreview.duration;
|
||||
}
|
||||
|
||||
const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60;
|
||||
const durationHours = displayDuration / 60;
|
||||
const top = startHour * PIXELS_PER_HOUR;
|
||||
const height = Math.max(durationHours * PIXELS_PER_HOUR, 30);
|
||||
|
||||
// Get lane info for overlapping appointments
|
||||
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
|
||||
const widthPercent = 100 / laneInfo.totalLanes;
|
||||
const leftPercent = laneInfo.lane * widthPercent;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className={`absolute bg-brand-100 dark:bg-brand-900/50 border-t-4 border-brand-500 rounded-b px-2 py-1 overflow-hidden cursor-move select-none group transition-shadow ${
|
||||
isDragging || isResizing ? 'shadow-lg ring-2 ring-brand-500 z-20' : 'hover:shadow-md z-10'
|
||||
}`}
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
left: `${leftPercent}%`,
|
||||
width: `calc(${widthPercent}% - 8px)`,
|
||||
}}
|
||||
onMouseDown={(e) => handleDragStart(e, apt)}
|
||||
>
|
||||
{/* Top resize handle */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => handleResizeStart(e, apt, 'top')}
|
||||
/>
|
||||
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate pointer-events-none mt-2">
|
||||
{apt.customerName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 pointer-events-none">
|
||||
<Clock size={10} />
|
||||
{format(displayStartTime, 'h:mm a')} • {formatDuration(displayDuration)}
|
||||
</div>
|
||||
|
||||
{/* Bottom resize handle */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => handleResizeStart(e, apt, 'bottom')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Current time indicator */}
|
||||
{isToday(currentDate) && (
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t-2 border-red-500 z-30 pointer-events-none"
|
||||
style={{
|
||||
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
|
||||
}}
|
||||
>
|
||||
<div className="absolute -left-1.5 -top-1.5 w-3 h-3 bg-red-500 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekView = () => {
|
||||
// Full week Monday to Sunday
|
||||
const days = eachDayOfInterval({
|
||||
start: getMonday(currentDate),
|
||||
end: addDays(getMonday(currentDate), 6)
|
||||
});
|
||||
|
||||
const dayStart = startOfDay(days[0]);
|
||||
const hours = eachHourOfInterval({
|
||||
start: dayStart,
|
||||
end: new Date(dayStart.getTime() + 23 * 60 * 60 * 1000)
|
||||
});
|
||||
|
||||
const DAY_COLUMN_WIDTH = 200; // pixels per day column
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Day headers - fixed at top */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex-shrink-0">
|
||||
<div className="w-16 flex-shrink-0" /> {/* Spacer for time column */}
|
||||
<div className="flex overflow-hidden">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`flex-shrink-0 text-center py-2 font-medium text-sm border-l border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||
isToday(day) ? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20' : 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
style={{ width: DAY_COLUMN_WIDTH }}
|
||||
onClick={() => {
|
||||
setCurrentDate(day);
|
||||
setViewMode('day');
|
||||
}}
|
||||
>
|
||||
{format(day, 'EEE, MMM d')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline grid */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Time labels - fixed left column */}
|
||||
<div ref={timeLabelsRef} className="w-16 flex-shrink-0 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
<div style={{ height: hours.length * PIXELS_PER_HOUR }}>
|
||||
{hours.map((hour) => (
|
||||
<div key={hour.toISOString()} className="relative" style={{ height: PIXELS_PER_HOUR }}>
|
||||
<div className="absolute top-0 right-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{format(hour, 'h a')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day columns with appointments - scrollable both ways */}
|
||||
<div className="flex-1 overflow-auto" ref={timelineRef}>
|
||||
<div className="flex" style={{ height: hours.length * PIXELS_PER_HOUR, width: days.length * DAY_COLUMN_WIDTH }}>
|
||||
{days.map((day) => {
|
||||
const dayAppointments = getAppointmentsForDay(day);
|
||||
const laneAssignments = calculateLanes(dayAppointments);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="relative flex-shrink-0 border-l border-gray-200 dark:border-gray-700"
|
||||
style={{ width: DAY_COLUMN_WIDTH }}
|
||||
onClick={() => {
|
||||
setCurrentDate(day);
|
||||
setViewMode('day');
|
||||
}}
|
||||
>
|
||||
{/* Hour grid lines */}
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour.toISOString()}
|
||||
className="border-b border-gray-100 dark:border-gray-800"
|
||||
style={{ height: PIXELS_PER_HOUR }}
|
||||
>
|
||||
<div className="absolute left-0 right-0 border-t border-dashed border-gray-100 dark:border-gray-800" style={{ top: PIXELS_PER_HOUR / 2 }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Appointments for this day */}
|
||||
{dayAppointments.map((apt) => {
|
||||
const aptStartTime = new Date(apt.startTime);
|
||||
const startHour = aptStartTime.getHours() + aptStartTime.getMinutes() / 60;
|
||||
const durationHours = apt.durationMinutes / 60;
|
||||
const top = startHour * PIXELS_PER_HOUR;
|
||||
const height = Math.max(durationHours * PIXELS_PER_HOUR, 24);
|
||||
|
||||
const laneInfo = laneAssignments.get(apt.id) || { lane: 0, totalLanes: 1 };
|
||||
const widthPercent = 100 / laneInfo.totalLanes;
|
||||
const leftPercent = laneInfo.lane * widthPercent;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="absolute bg-brand-100 dark:bg-brand-900/50 border-t-2 border-brand-500 rounded-b px-1 py-0.5 overflow-hidden cursor-pointer hover:shadow-md hover:z-10 text-xs"
|
||||
style={{
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
left: `${leftPercent}%`,
|
||||
width: `calc(${widthPercent}% - 4px)`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentDate(day);
|
||||
setViewMode('day');
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{apt.customerName}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 truncate">
|
||||
{format(aptStartTime, 'h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Current time indicator for today */}
|
||||
{isToday(day) && (
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t-2 border-red-500 z-20 pointer-events-none"
|
||||
style={{
|
||||
top: `${(new Date().getHours() + new Date().getMinutes() / 60) * PIXELS_PER_HOUR}px`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMonthView = () => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
// Start padding from Monday (weekStartsOn: 1)
|
||||
const startDayOfWeek = getDay(monthStart);
|
||||
// Adjust for Monday start: if Sunday (0), it's 6 days from Monday; otherwise subtract 1
|
||||
const paddingDays = Array(startDayOfWeek === 0 ? 6 : startDayOfWeek - 1).fill(null);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-500 dark:text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{paddingDays.map((_, index) => (
|
||||
<div key={`padding-${index}`} className="min-h-20" />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const dayAppointments = getAppointmentsForDay(day);
|
||||
const dayOfWeek = getDay(day);
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`min-h-20 p-2 border border-gray-200 dark:border-gray-700 rounded cursor-pointer hover:border-brand-300 dark:hover:border-brand-700 transition-colors ${
|
||||
isToday(day) ? 'bg-brand-50 dark:bg-brand-900/20' : isWeekend ? 'bg-gray-50 dark:bg-gray-900/30' : 'bg-white dark:bg-gray-800'
|
||||
}`}
|
||||
onClick={() => {
|
||||
// Drill down to week view showing the week containing this day
|
||||
setCurrentDate(day);
|
||||
setViewMode('week');
|
||||
}}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${isToday(day) ? 'text-brand-600 dark:text-brand-400' : isWeekend ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
{dayAppointments.length > 0 && (
|
||||
<div className="text-xs">
|
||||
<div className="text-brand-600 dark:text-brand-400 font-medium">
|
||||
{dayAppointments.length} appt{dayAppointments.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-6xl h-[80vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{resourceName} Calendar</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{viewMode === 'day' ? 'Drag to move, drag edges to resize' : 'Click a day to view details'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={navigatePrevious}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1 text-sm font-medium bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={navigateNext}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<div className="ml-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{getTitle()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Mode Selector */}
|
||||
<div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
{(['day', 'week', 'month'] as ViewMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded transition-colors capitalize ${viewMode === mode
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Content */}
|
||||
<div className="flex-1 min-h-0 flex flex-col relative">
|
||||
{viewMode === 'day' && renderDayView()}
|
||||
{viewMode === 'week' && renderWeekView()}
|
||||
{viewMode === 'month' && renderMonthView()}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-gray-400 dark:text-gray-500">Loading appointments...</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && appointments.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-gray-400 dark:text-gray-500">No appointments scheduled for this period</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceCalendar;
|
||||
162
frontend/src/components/Schedule/DraggableEvent.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { clsx } from 'clsx';
|
||||
import { Clock, DollarSign } from 'lucide-react';
|
||||
|
||||
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||
|
||||
export interface DraggableEventProps {
|
||||
id: number;
|
||||
title: string;
|
||||
serviceName?: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
status?: AppointmentStatus;
|
||||
isPaid?: boolean;
|
||||
height: number;
|
||||
left: number;
|
||||
width: number;
|
||||
top: number;
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
export const DraggableEvent: React.FC<DraggableEventProps> = ({
|
||||
id,
|
||||
title,
|
||||
serviceName,
|
||||
start,
|
||||
end,
|
||||
status = 'CONFIRMED',
|
||||
isPaid = false,
|
||||
height,
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `event-${id}`,
|
||||
data: {
|
||||
type: 'event',
|
||||
title,
|
||||
duration: (end.getTime() - start.getTime()) / 60000
|
||||
},
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
height,
|
||||
position: 'absolute',
|
||||
zIndex: isDragging ? 50 : 10,
|
||||
};
|
||||
|
||||
// Status Logic matching legacy OwnerScheduler.tsx exactly
|
||||
const getStatusStyles = () => {
|
||||
const now = new Date();
|
||||
|
||||
// Legacy: if (status === 'COMPLETED' || status === 'NO_SHOW')
|
||||
if (status === 'COMPLETED' || status === 'NO_SHOW') {
|
||||
return {
|
||||
container: 'bg-gray-100 border-gray-400 text-gray-600 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
|
||||
accent: 'bg-gray-400'
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy: if (status === 'CANCELLED')
|
||||
if (status === 'CANCELLED') {
|
||||
return {
|
||||
container: 'bg-gray-100 border-gray-400 text-gray-500 opacity-75 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400',
|
||||
accent: 'bg-gray-400'
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy: if (now > endTime) (Overdue)
|
||||
if (now > end) {
|
||||
return {
|
||||
container: 'bg-red-100 border-red-500 text-red-900 dark:bg-red-900/50 dark:border-red-500 dark:text-red-200',
|
||||
accent: 'bg-red-500'
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy: if (now >= startTime && now <= endTime) (In Progress)
|
||||
if (now >= start && now <= end) {
|
||||
return {
|
||||
container: 'bg-yellow-100 border-yellow-500 text-yellow-900 dark:bg-yellow-900/50 dark:border-yellow-500 dark:text-yellow-200',
|
||||
accent: 'bg-yellow-500 animate-pulse'
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy: Default (Future)
|
||||
return {
|
||||
container: 'bg-blue-100 border-blue-500 text-blue-900 dark:bg-blue-900/50 dark:border-blue-500 dark:text-blue-200',
|
||||
accent: 'bg-blue-500'
|
||||
};
|
||||
};
|
||||
|
||||
const styles = getStatusStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={clsx(
|
||||
"rounded-md border shadow-sm text-xs overflow-hidden cursor-pointer group transition-all select-none flex",
|
||||
styles.container,
|
||||
isDragging ? "opacity-50 ring-2 ring-blue-500 ring-offset-2 z-50 shadow-xl" : "hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
{/* Colored Status Strip */}
|
||||
<div className={clsx("w-1.5 shrink-0", styles.accent)} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-1.5 min-w-0 flex flex-col justify-center">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-semibold truncate">
|
||||
{title}
|
||||
</span>
|
||||
{isPaid && (
|
||||
<DollarSign size={10} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{serviceName && width > 100 && (
|
||||
<div className="text-[10px] opacity-80 truncate">
|
||||
{serviceName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time (only show if wide enough) */}
|
||||
{width > 60 && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[10px] opacity-70">
|
||||
<Clock size={8} />
|
||||
<span className="truncate">
|
||||
{start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resize Handles */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'left', id);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-ew-resize hover:bg-blue-400/50 z-20 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'right', id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
frontend/src/components/Schedule/PendingSidebar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Clock, GripVertical } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface PendingAppointment {
|
||||
id: number;
|
||||
customerName: string;
|
||||
serviceName: string;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
interface PendingItemProps {
|
||||
appointment: PendingAppointment;
|
||||
}
|
||||
|
||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `pending-${appointment.id}`,
|
||||
data: {
|
||||
type: 'pending',
|
||||
duration: appointment.durationMinutes,
|
||||
title: appointment.customerName // Pass title for the new event
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={clsx(
|
||||
"p-3 bg-white border border-l-4 border-gray-200 border-l-orange-400 rounded shadow-sm cursor-grab hover:shadow-md transition-all mb-2",
|
||||
isDragging ? "opacity-50" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-gray-900">{appointment.customerName}</p>
|
||||
<p className="text-xs text-gray-500">{appointment.serviceName}</p>
|
||||
</div>
|
||||
<GripVertical size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
|
||||
<Clock size={10} />
|
||||
<span>{appointment.durationMinutes} min</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PendingSidebarProps {
|
||||
appointments: PendingAppointment[];
|
||||
}
|
||||
|
||||
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
|
||||
return (
|
||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-100">
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2">
|
||||
<Clock size={12} /> Pending Requests ({appointments.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto flex-1">
|
||||
{appointments.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
|
||||
) : (
|
||||
appointments.map(apt => (
|
||||
<PendingItem key={apt.id} appointment={apt} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingSidebar;
|
||||
133
frontend/src/components/Schedule/Sidebar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Clock, GripVertical, Trash2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface PendingAppointment {
|
||||
id: number;
|
||||
customerName: string;
|
||||
serviceName: string;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
export interface ResourceLayout {
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
height: number;
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
interface PendingItemProps {
|
||||
appointment: PendingAppointment;
|
||||
}
|
||||
|
||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `pending-${appointment.id}`,
|
||||
data: {
|
||||
type: 'pending',
|
||||
duration: appointment.durationMinutes,
|
||||
title: appointment.customerName
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={clsx(
|
||||
"p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all mb-2",
|
||||
isDragging ? "opacity-50" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-white">{appointment.customerName}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{appointment.serviceName}</p>
|
||||
</div>
|
||||
<GripVertical size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Clock size={10} />
|
||||
<span>{appointment.durationMinutes} min</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
resourceLayouts: ResourceLayout[];
|
||||
pendingAppointments: PendingAppointment[];
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: 250 }}>
|
||||
{/* Resources Header */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: 48 }}>
|
||||
Resources
|
||||
</div>
|
||||
|
||||
{/* Resources List (Synced Scroll) */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-hidden flex-1" // Hidden scrollbar, controlled by main timeline
|
||||
>
|
||||
{resourceLayouts.map(layout => (
|
||||
<div
|
||||
key={layout.resourceId}
|
||||
className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
|
||||
style={{ height: layout.height }}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resourceName}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
|
||||
Resource
|
||||
{layout.laneCount > 1 && (
|
||||
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/50 px-1 rounded text-[10px]">
|
||||
{layout.laneCount} lanes
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Requests (Fixed Bottom) */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200">
|
||||
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0">
|
||||
<Clock size={12} /> Pending Requests ({pendingAppointments.length})
|
||||
</h3>
|
||||
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
|
||||
{pendingAppointments.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
|
||||
) : (
|
||||
pendingAppointments.map(apt => (
|
||||
<PendingItem key={apt.id} appointment={apt} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archive Drop Zone (Visual) */}
|
||||
<div className="shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 opacity-50">
|
||||
<div className="flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700 bg-transparent text-gray-400">
|
||||
<Trash2 size={16} />
|
||||
<span className="text-xs font-medium">Drop here to archive</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
443
frontend/src/components/Schedule/Timeline.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
DragOverlay
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
addMinutes,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isSameDay
|
||||
} from 'date-fns';
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Filter, Calendar as CalendarIcon, Undo, Redo, Clock, GripVertical } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import TimelineRow from '../Timeline/TimelineRow';
|
||||
import CurrentTimeIndicator from '../Timeline/CurrentTimeIndicator';
|
||||
import Sidebar from './Sidebar';
|
||||
import { Event, Resource, PendingAppointment } from '../../types';
|
||||
import { calculateLayout } from '../../lib/layoutAlgorithm';
|
||||
import { DEFAULT_PIXELS_PER_HOUR, SNAP_MINUTES } from '../../lib/timelineUtils';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adaptResources, adaptEvents, adaptPending } from '../../lib/uiAdapter';
|
||||
import axios from 'axios';
|
||||
|
||||
type ViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
export const Timeline: React.FC = () => {
|
||||
// Data Fetching
|
||||
const { data: resources = [] } = useQuery({
|
||||
queryKey: ['resources'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get('http://lvh.me:8000/api/resources/');
|
||||
return adaptResources(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
const { data: backendAppointments = [] } = useQuery({ // Renamed to backendAppointments to avoid conflict with localEvents
|
||||
queryKey: ['appointments'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get('http://lvh.me:8000/api/appointments/');
|
||||
return response.data; // Still return raw data, adapt in useEffect
|
||||
}
|
||||
});
|
||||
|
||||
// State
|
||||
const [localEvents, setLocalEvents] = useState<Event[]>([]);
|
||||
const [localPending, setLocalPending] = useState<PendingAppointment[]>([]);
|
||||
|
||||
// Sync remote data to local state (for optimistic UI updates later)
|
||||
useEffect(() => {
|
||||
if (backendAppointments.length > 0) {
|
||||
setLocalEvents(adaptEvents(backendAppointments));
|
||||
setLocalPending(adaptPending(backendAppointments));
|
||||
}
|
||||
}, [backendAppointments]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [pixelsPerHour, setPixelsPerHour] = useState(DEFAULT_PIXELS_PER_HOUR);
|
||||
const [activeDragItem, setActiveDragItem] = useState<any>(null);
|
||||
|
||||
const timelineScrollRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
// Sensors for drag detection
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Calculate view range
|
||||
const { startTime, endTime, days } = useMemo(() => {
|
||||
let start, end;
|
||||
|
||||
if (viewMode === 'day') {
|
||||
start = startOfDay(currentDate);
|
||||
end = endOfDay(currentDate);
|
||||
} else if (viewMode === 'week') {
|
||||
start = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
end = endOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
} else {
|
||||
start = startOfMonth(currentDate);
|
||||
end = endOfMonth(currentDate);
|
||||
}
|
||||
|
||||
const days = eachDayOfInterval({ start, end });
|
||||
return { startTime: start, endTime: end, days };
|
||||
}, [viewMode, currentDate]);
|
||||
|
||||
// Calculate Layouts for Sidebar Sync
|
||||
const resourceLayouts = useMemo<ResourceLayout[]>(() => {
|
||||
return resources.map(resource => {
|
||||
const resourceEvents = localEvents.filter(e => e.resourceId === resource.id);
|
||||
const eventsWithLanes = calculateLayout(resourceEvents);
|
||||
const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0));
|
||||
const height = (maxLane + 1) * 40 + 20; // 40 is eventHeight, 20 is padding
|
||||
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
resourceName: resource.name,
|
||||
height,
|
||||
laneCount: maxLane + 1
|
||||
};
|
||||
});
|
||||
}, [resources, localEvents]);
|
||||
|
||||
// Scroll Sync Logic
|
||||
const handleTimelineScroll = () => {
|
||||
if (timelineScrollRef.current && sidebarScrollRef.current) {
|
||||
sidebarScrollRef.current.scrollTop = timelineScrollRef.current.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
// Date Range Label
|
||||
const getDateRangeLabel = () => {
|
||||
if (viewMode === 'day') {
|
||||
return format(currentDate, 'EEEE, MMMM d, yyyy');
|
||||
} else if (viewMode === 'week') {
|
||||
const start = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const end = endOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
return `${format(start, 'MMM d')} - ${format(end, 'MMM d, yyyy')}`;
|
||||
} else {
|
||||
return format(currentDate, 'MMMM yyyy');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (timelineScrollRef.current && !hasScrolledRef.current) {
|
||||
const indicator = document.getElementById('current-time-indicator');
|
||||
if (indicator) {
|
||||
indicator.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
hasScrolledRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [startTime, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledRef.current = false;
|
||||
}, [viewMode]);
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
setActiveDragItem(event.active.data.current);
|
||||
};
|
||||
|
||||
// Handle Drag End
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, delta, over } = event;
|
||||
setActiveDragItem(null);
|
||||
if (!active) return;
|
||||
|
||||
let newResourceId: number | undefined;
|
||||
if (over && over.id.toString().startsWith('resource-')) {
|
||||
newResourceId = Number(over.id.toString().replace('resource-', ''));
|
||||
}
|
||||
|
||||
// Handle Pending Event Drop
|
||||
if (active.data.current?.type === 'pending') {
|
||||
if (newResourceId) {
|
||||
const pendingId = Number(active.id.toString().replace('pending-', ''));
|
||||
const pendingItem = localPending.find(p => p.id === pendingId);
|
||||
|
||||
if (pendingItem) {
|
||||
const dropRect = active.rect.current.translated;
|
||||
const containerRect = timelineScrollRef.current?.getBoundingClientRect();
|
||||
|
||||
if (dropRect && containerRect) {
|
||||
// Calculate relative X position in the timeline content
|
||||
const relativeX = dropRect.left - containerRect.left + (timelineScrollRef.current?.scrollLeft || 0);
|
||||
|
||||
const pixelsPerMinute = pixelsPerHour / 60;
|
||||
const minutesFromStart = Math.max(0, relativeX / pixelsPerMinute);
|
||||
const snappedMinutes = Math.round(minutesFromStart / SNAP_MINUTES) * SNAP_MINUTES;
|
||||
|
||||
const newStart = addMinutes(startTime, snappedMinutes);
|
||||
const newEnd = addMinutes(newStart, pendingItem.durationMinutes);
|
||||
|
||||
const newEvent: Event = {
|
||||
id: Date.now(),
|
||||
resourceId: newResourceId,
|
||||
title: pendingItem.customerName,
|
||||
start: newStart,
|
||||
end: newEnd,
|
||||
status: 'CONFIRMED'
|
||||
};
|
||||
|
||||
setLocalEvents(prev => [...prev, newEvent]);
|
||||
setLocalPending(prev => prev.filter(p => p.id !== pendingId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Existing Event Drag
|
||||
const eventId = Number(active.id.toString().replace('event-', ''));
|
||||
setLocalEvents(prev => prev.map(e => {
|
||||
if (e.id === eventId) {
|
||||
const minutesShift = Math.round(delta.x / (pixelsPerHour / 60));
|
||||
const snappedShift = Math.round(minutesShift / SNAP_MINUTES) * SNAP_MINUTES;
|
||||
|
||||
const updates: Partial<Event> = {};
|
||||
|
||||
if (snappedShift !== 0) {
|
||||
updates.start = addMinutes(e.start, snappedShift);
|
||||
updates.end = addMinutes(e.end, snappedShift);
|
||||
}
|
||||
|
||||
if (newResourceId !== undefined && newResourceId !== e.resourceId) {
|
||||
updates.resourceId = newResourceId;
|
||||
}
|
||||
|
||||
return { ...e, ...updates };
|
||||
}
|
||||
return e;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResizeStart = (_e: React.MouseEvent, direction: 'left' | 'right', id: number) => {
|
||||
console.log('Resize started', direction, id);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => setPixelsPerHour(prev => Math.min(prev + 20, 300));
|
||||
const handleZoomOut = () => setPixelsPerHour(prev => Math.max(prev - 20, 40));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
{/* Header Bar */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Date Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? -1440 : -10080))}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-md text-gray-700 dark:text-gray-200 font-medium transition-colors duration-200 w-[320px] justify-center">
|
||||
<CalendarIcon size={16} />
|
||||
<span className="text-center">{getDateRangeLabel()}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentDate(d => addMinutes(d, viewMode === 'day' ? 1440 : 10080))}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Switcher */}
|
||||
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
|
||||
{(['day', 'week', 'month'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-sm font-medium rounded transition-colors capitalize",
|
||||
viewMode === mode
|
||||
? "bg-blue-500 text-white"
|
||||
: "text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
onClick={handleZoomOut}
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Zoom</span>
|
||||
<button
|
||||
className="p-1.5 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
|
||||
onClick={handleZoomIn}
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="flex items-center gap-1 border-l border-gray-300 dark:border-gray-600 pl-4">
|
||||
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
|
||||
<Undo size={18} />
|
||||
</button>
|
||||
<button className="p-2 text-gray-300 dark:text-gray-600 cursor-not-allowed rounded" disabled>
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors shadow-sm">
|
||||
+ New Appointment
|
||||
</button>
|
||||
<button className="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 transition-colors">
|
||||
<Filter size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{/* Sidebar (Resources + Pending) */}
|
||||
<Sidebar
|
||||
resourceLayouts={resourceLayouts}
|
||||
pendingAppointments={localPending}
|
||||
scrollRef={sidebarScrollRef}
|
||||
/>
|
||||
|
||||
{/* Timeline Grid */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
|
||||
<div
|
||||
ref={timelineScrollRef}
|
||||
onScroll={handleTimelineScroll}
|
||||
className="flex-1 overflow-auto timeline-scroll"
|
||||
>
|
||||
<div className="min-w-max relative min-h-full">
|
||||
{/* Current Time Indicator */}
|
||||
<div className="absolute inset-y-0 left-0 right-0 pointer-events-none z-40">
|
||||
<CurrentTimeIndicator startTime={startTime} hourWidth={pixelsPerHour} />
|
||||
</div>
|
||||
|
||||
{/* Header Row */}
|
||||
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
|
||||
<div className="flex" style={{ height: 48 }}>
|
||||
{viewMode === 'day' ? (
|
||||
Array.from({ length: 24 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm text-gray-500 font-medium box-border"
|
||||
style={{ width: pixelsPerHour }}
|
||||
>
|
||||
{format(new Date().setHours(i, 0, 0, 0), 'h a')}
|
||||
</div>
|
||||
))
|
||||
) : viewMode === 'week' ? (
|
||||
days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 border-r border-gray-300 dark:border-gray-600"
|
||||
style={{ width: pixelsPerHour * 24 }}
|
||||
>
|
||||
<div className={clsx(
|
||||
"p-2 text-sm font-bold text-center border-b border-gray-100 dark:border-gray-700",
|
||||
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||
)}>
|
||||
{format(day, 'EEEE, MMM d')}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{Array.from({ length: 24 }).map((_, h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-1 text-xs text-gray-400 text-center"
|
||||
style={{ width: pixelsPerHour }}
|
||||
>
|
||||
{h % 6 === 0 ? format(new Date().setHours(h, 0, 0, 0), 'h a') : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r border-gray-100 dark:border-gray-700/50 p-2 text-sm font-medium text-center",
|
||||
isSameDay(day, new Date()) ? "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" : "text-gray-500"
|
||||
)}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Rows (Grid Only) */}
|
||||
{resourceLayouts.map(layout => (
|
||||
<TimelineRow
|
||||
key={layout.resourceId}
|
||||
resourceId={layout.resourceId}
|
||||
events={localEvents.filter(e => e.resourceId === layout.resourceId)}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
hourWidth={pixelsPerHour}
|
||||
eventHeight={40}
|
||||
height={layout.height}
|
||||
onResizeStart={handleResizeStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay for Visual Feedback */}
|
||||
<DragOverlay>
|
||||
{activeDragItem ? (
|
||||
<div className="p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-lg opacity-80 w-64">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-white">{activeDragItem.title}</p>
|
||||
</div>
|
||||
<GripVertical size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Clock size={10} />
|
||||
<span>{activeDragItem.duration} min</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
89
frontend/src/components/ServiceList.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.service-list {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.service-list h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.service-card h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.service-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.service-duration {
|
||||
background: #edf2f7;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.service-price {
|
||||
font-weight: bold;
|
||||
color: #2b6cb0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.service-description {
|
||||
color: #718096;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.service-book-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.service-book-btn:hover {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.service-list-loading,
|
||||
.service-list-empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #718096;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
39
frontend/src/components/ServiceList.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import './ServiceList.css';
|
||||
|
||||
const ServiceList = ({ services, onSelectService, loading }) => {
|
||||
if (loading) {
|
||||
return <div className="service-list-loading">Loading services...</div>;
|
||||
}
|
||||
|
||||
if (!services || services.length === 0) {
|
||||
return <div className="service-list-empty">No services available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-list">
|
||||
<h2>Available Services</h2>
|
||||
<div className="service-grid">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="service-card"
|
||||
onClick={() => onSelectService(service)}
|
||||
>
|
||||
<h3>{service.name}</h3>
|
||||
<div className="service-details">
|
||||
<span className="service-duration">{service.duration} min</span>
|
||||
<span className="service-price">${service.price}</span>
|
||||
</div>
|
||||
{service.description && (
|
||||
<p className="service-description">{service.description}</p>
|
||||
)}
|
||||
<button className="service-book-btn">Book Now</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceList;
|
||||
174
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CalendarDays,
|
||||
Settings,
|
||||
Users,
|
||||
CreditCard,
|
||||
MessageSquare,
|
||||
LogOut,
|
||||
ClipboardList,
|
||||
Briefcase
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
interface SidebarProps {
|
||||
business: Business;
|
||||
user: User;
|
||||
isCollapsed: boolean;
|
||||
toggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCollapse }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { role } = user;
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const getNavClass = (path: string, exact: boolean = false, disabled: boolean = false) => {
|
||||
const isActive = exact
|
||||
? location.pathname === path
|
||||
: location.pathname.startsWith(path);
|
||||
|
||||
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||
const activeClasses = 'bg-opacity-10 text-white bg-white';
|
||||
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
const disabledClasses = 'text-white/30 cursor-not-allowed';
|
||||
|
||||
if (disabled) {
|
||||
return `${baseClasses} ${collapsedClasses} ${disabledClasses}`;
|
||||
}
|
||||
|
||||
return `${baseClasses} ${collapsedClasses} ${isActive ? activeClasses : inactiveClasses}`;
|
||||
};
|
||||
|
||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
||||
const canViewSettings = role === 'owner';
|
||||
|
||||
const getDashboardLink = () => {
|
||||
if (role === 'resource') return '/';
|
||||
return '/';
|
||||
};
|
||||
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full text-white shrink-0 transition-all duration-300 ${isCollapsed ? 'w-20' : 'w-64'}`}
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<nav className="flex-1 px-4 space-y-1 overflow-y-auto">
|
||||
<Link to={getDashboardLink()} className={getNavClass('/', true)} title={t('nav.dashboard')}>
|
||||
<LayoutDashboard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.dashboard')}</span>}
|
||||
</Link>
|
||||
|
||||
<Link to="/scheduler" className={getNavClass('/scheduler')} title={t('nav.scheduler')}>
|
||||
<CalendarDays size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.scheduler')}</span>}
|
||||
</Link>
|
||||
|
||||
{canViewManagementPages && (
|
||||
<>
|
||||
<Link to="/customers" className={getNavClass('/customers')} title={t('nav.customers')}>
|
||||
<Users size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.customers')}</span>}
|
||||
</Link>
|
||||
<Link to="/services" className={getNavClass('/services')} title={t('nav.services', 'Services')}>
|
||||
<Briefcase size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.services', 'Services')}</span>}
|
||||
</Link>
|
||||
<Link to="/resources" className={getNavClass('/resources')} title={t('nav.resources')}>
|
||||
<ClipboardList size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.resources')}</span>}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
{business.paymentsEnabled ? (
|
||||
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={getNavClass('/payments', false, true)}
|
||||
title={t('nav.paymentsDisabledTooltip')}
|
||||
>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</div>
|
||||
)}
|
||||
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
|
||||
<MessageSquare size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.messages')}</span>}
|
||||
</Link>
|
||||
<Link to="/staff" className={getNavClass('/staff')} title={t('nav.staff')}>
|
||||
<Users size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canViewSettings && (
|
||||
<div className="pt-8 mt-8 border-t border-white/10">
|
||||
{canViewSettings && (
|
||||
<Link to="/settings" className={getNavClass('/settings', true)} title={t('nav.businessSettings')}>
|
||||
<Settings size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.businessSettings')}</span>}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<SmoothScheduleLogo className="w-6 h-6 text-white" />
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<span className="block">{t('common.poweredBy')}</span>
|
||||
<span className="font-semibold text-white/80">Smooth Schedule</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={logoutMutation.isPending}
|
||||
className={`flex items-center gap-3 px-4 py-2 text-sm font-medium text-white/70 hover:text-white w-full transition-colors rounded-lg ${isCollapsed ? 'justify-center' : ''} disabled:opacity-50`}
|
||||
>
|
||||
<LogOut size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('auth.signOut')}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
24
frontend/src/components/SmoothScheduleLogo.tsx
Normal file
441
frontend/src/components/StripeApiKeysForm.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* Stripe API Keys Form Component
|
||||
* For free-tier businesses to enter and manage their Stripe API keys
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
FlaskConical,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { ApiKeysInfo } from '../api/payments';
|
||||
import {
|
||||
useValidateApiKeys,
|
||||
useSaveApiKeys,
|
||||
useDeleteApiKeys,
|
||||
useRevalidateApiKeys,
|
||||
} from '../hooks/usePayments';
|
||||
|
||||
interface StripeApiKeysFormProps {
|
||||
apiKeys: ApiKeysInfo | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [publishableKey, setPublishableKey] = useState('');
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean;
|
||||
accountName?: string;
|
||||
environment?: string;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const validateMutation = useValidateApiKeys();
|
||||
const saveMutation = useSaveApiKeys();
|
||||
const deleteMutation = useDeleteApiKeys();
|
||||
const revalidateMutation = useRevalidateApiKeys();
|
||||
|
||||
const isConfigured = apiKeys && apiKeys.status !== 'deprecated';
|
||||
const isDeprecated = apiKeys?.status === 'deprecated';
|
||||
const isInvalid = apiKeys?.status === 'invalid';
|
||||
|
||||
// Determine if using test or live keys from the masked key prefix
|
||||
const getKeyEnvironment = (maskedKey: string | undefined): 'test' | 'live' | null => {
|
||||
if (!maskedKey) return null;
|
||||
if (maskedKey.startsWith('pk_test_') || maskedKey.startsWith('sk_test_')) return 'test';
|
||||
if (maskedKey.startsWith('pk_live_') || maskedKey.startsWith('sk_live_')) return 'live';
|
||||
return null;
|
||||
};
|
||||
const keyEnvironment = getKeyEnvironment(apiKeys?.publishable_key_masked);
|
||||
|
||||
const handleValidate = async () => {
|
||||
setValidationResult(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync({ secretKey, publishableKey });
|
||||
setValidationResult({
|
||||
valid: result.valid,
|
||||
accountName: result.account_name,
|
||||
environment: result.environment,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error: any) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: error.response?.data?.error || 'Validation failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveMutation.mutateAsync({ secretKey, publishableKey });
|
||||
setSecretKey('');
|
||||
setPublishableKey('');
|
||||
setValidationResult(null);
|
||||
onSuccess?.();
|
||||
} catch (error: any) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: error.response?.data?.error || 'Failed to save keys',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
setShowDeleteConfirm(false);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevalidate = async () => {
|
||||
try {
|
||||
await revalidateMutation.mutateAsync();
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to revalidate keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const canSave = validationResult?.valid && secretKey && publishableKey;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Configuration */}
|
||||
{isConfigured && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CheckCircle size={18} className="text-green-500" />
|
||||
Stripe Keys Configured
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Environment Badge */}
|
||||
{keyEnvironment && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full ${
|
||||
keyEnvironment === 'test'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{keyEnvironment === 'test' ? (
|
||||
<>
|
||||
<FlaskConical size={12} />
|
||||
Test Mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={12} />
|
||||
Live Mode
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Status Badge */}
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
apiKeys.status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: apiKeys.status === 'invalid'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{apiKeys.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
|
||||
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
|
||||
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
|
||||
</div>
|
||||
{apiKeys.stripe_account_name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Account:</span>
|
||||
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.last_validated_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Mode Warning */}
|
||||
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
||||
<FlaskConical size={16} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
You are using <strong>test keys</strong>. Payments will not be processed for real.
|
||||
Switch to live keys when ready to accept real payments.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInvalid && apiKeys.validation_error && (
|
||||
<div className="mt-3 p-2 bg-red-50 rounded text-sm text-red-700">
|
||||
{apiKeys.validation_error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={handleRevalidate}
|
||||
disabled={revalidateMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{revalidateMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Re-validate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deprecated Notice */}
|
||||
{isDeprecated && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Your API keys have been deprecated because you upgraded to a paid tier.
|
||||
Please complete Stripe Connect onboarding to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Update Keys Form */}
|
||||
{(!isConfigured || isDeprecated) && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
|
||||
</h4>
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
Enter your Stripe API keys to enable payment collection.
|
||||
You can find these in your{' '}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Stripe Dashboard
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Publishable Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Publishable Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={publishableKey}
|
||||
onChange={(e) => {
|
||||
setPublishableKey(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
placeholder="pk_test_..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Secret Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type={showSecretKey ? 'text' : 'password'}
|
||||
value={secretKey}
|
||||
onChange={(e) => {
|
||||
setSecretKey(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
placeholder="sk_test_..."
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecretKey(!showSecretKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{validationResult && (
|
||||
<div
|
||||
className={`flex items-start gap-2 p-3 rounded-lg ${
|
||||
validationResult.valid
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{validationResult.valid ? (
|
||||
<CheckCircle size={18} className="shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="text-sm flex-1">
|
||||
{validationResult.valid ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Keys are valid!</span>
|
||||
{validationResult.environment && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
validationResult.environment === 'test'
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{validationResult.environment === 'test' ? (
|
||||
<>
|
||||
<FlaskConical size={10} />
|
||||
Test Mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={10} />
|
||||
Live Mode
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{validationResult.accountName && (
|
||||
<div>Connected to: {validationResult.accountName}</div>
|
||||
)}
|
||||
{validationResult.environment === 'test' && (
|
||||
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
|
||||
These are test keys. No real payments will be processed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span>{validationResult.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={!secretKey || !publishableKey || validateMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{validateMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || saveMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Key size={16} />
|
||||
)}
|
||||
Save Keys
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Remove API Keys?
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Are you sure you want to remove your Stripe API keys?
|
||||
You will not be able to accept payments until you add them again.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeApiKeysForm;
|
||||
38
frontend/src/components/Timeline/CurrentTimeIndicator.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface CurrentTimeIndicatorProps {
|
||||
startTime: Date;
|
||||
hourWidth: number;
|
||||
}
|
||||
|
||||
const CurrentTimeIndicator: React.FC<CurrentTimeIndicatorProps> = ({ startTime, hourWidth }) => {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60000); // Update every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Calculate position
|
||||
const left = getPosition(now, startTime, hourWidth);
|
||||
|
||||
// Only render if within visible range (roughly)
|
||||
if (differenceInMinutes(now, startTime) < 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none"
|
||||
style={{ left }}
|
||||
id="current-time-indicator"
|
||||
>
|
||||
<div className="absolute -top-1 -left-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
<div className="absolute top-0 left-2 text-xs font-bold text-red-500 bg-white/80 px-1 rounded">
|
||||
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentTimeIndicator;
|
||||
117
frontend/src/components/Timeline/DraggableEvent.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { format } from 'date-fns';
|
||||
import { clsx } from 'clsx';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
interface DraggableEventProps {
|
||||
id: number;
|
||||
title: string;
|
||||
serviceName?: string;
|
||||
status?: 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW' | 'SCHEDULED';
|
||||
isPaid?: boolean;
|
||||
start: Date;
|
||||
end: Date;
|
||||
laneIndex: number;
|
||||
height: number;
|
||||
left: number;
|
||||
width: number;
|
||||
top: number;
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
export const DraggableEvent: React.FC<DraggableEventProps> = ({
|
||||
id,
|
||||
title,
|
||||
serviceName,
|
||||
status = 'SCHEDULED',
|
||||
isPaid = false,
|
||||
start,
|
||||
end,
|
||||
height,
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `event-${id}`,
|
||||
data: { id, type: 'event', originalStart: start, originalEnd: end },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
left,
|
||||
width,
|
||||
top,
|
||||
height,
|
||||
};
|
||||
|
||||
// Status-based color scheme matching reference UI
|
||||
const getBorderColor = () => {
|
||||
if (isPaid) return 'border-green-500';
|
||||
switch (status) {
|
||||
case 'CONFIRMED': return 'border-blue-500';
|
||||
case 'COMPLETED': return 'border-green-500';
|
||||
case 'CANCELLED': return 'border-red-500';
|
||||
case 'NO_SHOW': return 'border-gray-500';
|
||||
default: return 'border-brand-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(
|
||||
"absolute rounded-b overflow-hidden group transition-shadow",
|
||||
"bg-brand-100 dark:bg-brand-900/50 border-t-4",
|
||||
getBorderColor(),
|
||||
isDragging ? "shadow-lg ring-2 ring-brand-500 opacity-80 z-50" : "hover:shadow-md z-10"
|
||||
)}
|
||||
>
|
||||
{/* Top Resize Handle */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'left', id);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="h-full w-full px-2 py-1 cursor-move select-none"
|
||||
>
|
||||
<div className="flex items-start justify-between mt-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<GripVertical size={14} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
{serviceName && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
|
||||
{serviceName}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{format(start, 'h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Resize Handle */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize hover:bg-brand-300/50 dark:hover:bg-brand-700/50"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onResizeStart(e, 'right', id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
frontend/src/components/Timeline/ResourceRow.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { clsx } from 'clsx';
|
||||
import { differenceInHours } from 'date-fns';
|
||||
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
|
||||
import { DraggableEvent } from './DraggableEvent';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface ResourceRowProps {
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
events: Event[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
hourWidth: number;
|
||||
eventHeight: number;
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
const ResourceRow: React.FC<ResourceRowProps> = ({
|
||||
resourceId,
|
||||
resourceName,
|
||||
events,
|
||||
startTime,
|
||||
endTime,
|
||||
hourWidth,
|
||||
eventHeight,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `resource-${resourceId}`,
|
||||
data: { resourceId },
|
||||
});
|
||||
|
||||
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
|
||||
const maxLane = Math.max(0, ...eventsWithLanes.map(e => e.laneIndex || 0));
|
||||
const rowHeight = (maxLane + 1) * eventHeight + 20;
|
||||
|
||||
const totalWidth = getPosition(endTime, startTime, hourWidth);
|
||||
|
||||
// Calculate total hours for grid lines
|
||||
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-gray-200 group">
|
||||
<div
|
||||
className="w-48 flex-shrink-0 p-4 border-r border-gray-200 bg-gray-50 font-medium flex items-center sticky left-0 z-10 group-hover:bg-gray-100 transition-colors"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
{resourceName}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"relative flex-grow transition-colors",
|
||||
isOver ? "bg-blue-50" : ""
|
||||
)}
|
||||
style={{ height: rowHeight, width: totalWidth }}
|
||||
>
|
||||
{/* Grid Lines */}
|
||||
<div className="absolute inset-0 pointer-events-none flex">
|
||||
{Array.from({ length: totalHours }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r border-gray-100 h-full"
|
||||
style={{ width: hourWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
{eventsWithLanes.map((event) => {
|
||||
const left = getPosition(event.start, startTime, hourWidth);
|
||||
const width = getPosition(event.end, startTime, hourWidth) - left;
|
||||
const top = (event.laneIndex || 0) * eventHeight + 10;
|
||||
|
||||
return (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
start={event.start}
|
||||
end={event.end}
|
||||
laneIndex={event.laneIndex || 0}
|
||||
height={eventHeight - 4}
|
||||
left={left}
|
||||
width={width}
|
||||
top={top}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceRow;
|
||||
88
frontend/src/components/Timeline/TimelineRow.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { clsx } from 'clsx';
|
||||
import { differenceInHours } from 'date-fns';
|
||||
import { calculateLayout, Event } from '../../lib/layoutAlgorithm';
|
||||
import { DraggableEvent } from './DraggableEvent';
|
||||
import { getPosition } from '../../lib/timelineUtils';
|
||||
|
||||
interface TimelineRowProps {
|
||||
resourceId: number;
|
||||
events: Event[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
hourWidth: number;
|
||||
eventHeight: number;
|
||||
height: number; // Passed from parent to match sidebar
|
||||
onResizeStart: (e: React.MouseEvent, direction: 'left' | 'right', id: number) => void;
|
||||
}
|
||||
|
||||
const TimelineRow: React.FC<TimelineRowProps> = ({
|
||||
resourceId,
|
||||
events,
|
||||
startTime,
|
||||
endTime,
|
||||
hourWidth,
|
||||
eventHeight,
|
||||
height,
|
||||
onResizeStart,
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `resource-${resourceId}`,
|
||||
data: { resourceId },
|
||||
});
|
||||
|
||||
const eventsWithLanes = useMemo(() => calculateLayout(events), [events]);
|
||||
const totalWidth = getPosition(endTime, startTime, hourWidth);
|
||||
const totalHours = Math.ceil(differenceInHours(endTime, startTime));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"relative border-b border-gray-200 dark:border-gray-700 transition-colors group",
|
||||
isOver ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
||||
)}
|
||||
style={{ height, width: totalWidth }}
|
||||
>
|
||||
{/* Grid Lines */}
|
||||
<div className="absolute inset-0 pointer-events-none flex">
|
||||
{Array.from({ length: totalHours }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r border-gray-100 dark:border-gray-700/50 h-full"
|
||||
style={{ width: hourWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
{eventsWithLanes.map((event) => {
|
||||
const left = getPosition(event.start, startTime, hourWidth);
|
||||
const width = getPosition(event.end, startTime, hourWidth) - left;
|
||||
const top = (event.laneIndex || 0) * eventHeight + 10;
|
||||
|
||||
return (
|
||||
<DraggableEvent
|
||||
key={event.id}
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
serviceName={event.serviceName}
|
||||
status={event.status}
|
||||
isPaid={event.isPaid}
|
||||
start={event.start}
|
||||
end={event.end}
|
||||
laneIndex={event.laneIndex || 0}
|
||||
height={eventHeight - 4}
|
||||
left={left}
|
||||
width={width}
|
||||
top={top}
|
||||
onResizeStart={onResizeStart}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineRow;
|
||||
61
frontend/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, Search, Moon, Sun, Menu } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import UserProfileDropdown from './UserProfileDropdown';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
interface TopBarProps {
|
||||
user: User;
|
||||
isDarkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
const TopBar: React.FC<TopBarProps> = ({ user, isDarkMode, toggleTheme, onMenuClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-16 px-4 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 transition-colors duration-200 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-2 -ml-2 text-gray-500 rounded-md md:hidden hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-brand-500"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="relative hidden md:block w-96">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
|
||||
<Search size={18} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('common.search')}
|
||||
className="w-full py-2 pl-10 pr-4 text-sm text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 placeholder-gray-400 dark:placeholder-gray-500 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSelector />
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
<button className="relative p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
<UserProfileDropdown user={user} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
549
frontend/src/components/TransactionDetailModal.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Transaction Detail Modal
|
||||
*
|
||||
* Displays comprehensive transaction information and provides refund functionality.
|
||||
* Supports both partial and full refunds with reason selection.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
CreditCard,
|
||||
User,
|
||||
Mail,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
RefreshCcw,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Receipt,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
ArrowLeftRight,
|
||||
Percent,
|
||||
} from 'lucide-react';
|
||||
import { TransactionDetail, RefundInfo, RefundRequest } from '../api/payments';
|
||||
import { useTransactionDetail, useRefundTransaction } from '../hooks/useTransactionAnalytics';
|
||||
import Portal from './Portal';
|
||||
|
||||
interface TransactionDetailModalProps {
|
||||
transactionId: number | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
transactionId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
|
||||
const refundMutation = useRefundTransaction();
|
||||
|
||||
// Refund form state
|
||||
const [showRefundForm, setShowRefundForm] = useState(false);
|
||||
const [refundType, setRefundType] = useState<'full' | 'partial'>('full');
|
||||
const [refundAmount, setRefundAmount] = useState('');
|
||||
const [refundReason, setRefundReason] = useState<RefundRequest['reason']>('requested_by_customer');
|
||||
const [refundError, setRefundError] = useState<string | null>(null);
|
||||
|
||||
if (!transactionId) return null;
|
||||
|
||||
const handleRefund = async () => {
|
||||
if (!transaction) return;
|
||||
|
||||
setRefundError(null);
|
||||
|
||||
const request: RefundRequest = {
|
||||
reason: refundReason,
|
||||
};
|
||||
|
||||
// For partial refunds, include the amount
|
||||
if (refundType === 'partial') {
|
||||
const amountCents = Math.round(parseFloat(refundAmount) * 100);
|
||||
if (isNaN(amountCents) || amountCents <= 0) {
|
||||
setRefundError('Please enter a valid refund amount');
|
||||
return;
|
||||
}
|
||||
if (amountCents > transaction.refundable_amount) {
|
||||
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
|
||||
return;
|
||||
}
|
||||
request.amount = amountCents;
|
||||
}
|
||||
|
||||
try {
|
||||
await refundMutation.mutateAsync({
|
||||
transactionId: transaction.id,
|
||||
request,
|
||||
});
|
||||
setShowRefundForm(false);
|
||||
setRefundAmount('');
|
||||
} catch (err: any) {
|
||||
setRefundError(err.response?.data?.error || 'Failed to process refund');
|
||||
}
|
||||
};
|
||||
|
||||
// Status badge helper
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
|
||||
succeeded: { bg: 'bg-green-100', text: 'text-green-800', icon: <CheckCircle size={14} /> },
|
||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: <Clock size={14} /> },
|
||||
failed: { bg: 'bg-red-100', text: 'text-red-800', icon: <XCircle size={14} /> },
|
||||
refunded: { bg: 'bg-gray-100', text: 'text-gray-800', icon: <RefreshCcw size={14} /> },
|
||||
partially_refunded: { bg: 'bg-orange-100', text: 'text-orange-800', icon: <RefreshCcw size={14} /> },
|
||||
};
|
||||
const style = styles[status] || styles.pending;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full ${style.bg} ${style.text}`}>
|
||||
{style.icon}
|
||||
{status.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateStr: string | number) => {
|
||||
const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Format timestamp for refunds
|
||||
const formatRefundDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Get payment method display
|
||||
const getPaymentMethodDisplay = () => {
|
||||
if (!transaction?.payment_method_info) return null;
|
||||
|
||||
const pm = transaction.payment_method_info;
|
||||
if (pm.type === 'card') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<CreditCard className="text-gray-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{pm.brand} **** {pm.last4}
|
||||
</p>
|
||||
{pm.exp_month && pm.exp_year && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Expires {pm.exp_month}/{pm.exp_year}
|
||||
{pm.funding && ` (${pm.funding})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<DollarSign className="text-gray-600" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 capitalize">{pm.type.replace('_', ' ')}</p>
|
||||
{pm.bank_name && <p className="text-sm text-gray-500">{pm.bank_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Transaction Details
|
||||
</h3>
|
||||
{transaction && (
|
||||
<p className="text-sm text-gray-500 font-mono">
|
||||
{transaction.stripe_payment_intent_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-gray-400" size={32} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={18} />
|
||||
<p className="font-medium">Failed to load transaction details</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transaction && (
|
||||
<>
|
||||
{/* Status & Amount */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
{getStatusBadge(transaction.status)}
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-2">
|
||||
{transaction.amount_display}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{transaction.transaction_type.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
{transaction.can_refund && !showRefundForm && (
|
||||
<button
|
||||
onClick={() => setShowRefundForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
Issue Refund
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refund Form */}
|
||||
{showRefundForm && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-red-800">
|
||||
<RefreshCcw size={18} />
|
||||
<h4 className="font-semibold">Issue Refund</h4>
|
||||
</div>
|
||||
|
||||
{/* Refund Type */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
checked={refundType === 'full'}
|
||||
onChange={() => setRefundType('full')}
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="refundType"
|
||||
checked={refundType === 'partial'}
|
||||
onChange={() => setRefundType('partial')}
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Partial refund</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Partial Amount */}
|
||||
{refundType === 'partial' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={(transaction.refundable_amount / 100).toFixed(2)}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full pl-7 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Reason
|
||||
</label>
|
||||
<select
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="requested_by_customer">Requested by customer</option>
|
||||
<option value="duplicate">Duplicate charge</option>
|
||||
<option value="fraudulent">Fraudulent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{refundError && (
|
||||
<div className="flex items-center gap-2 text-red-600 text-sm">
|
||||
<AlertCircle size={16} />
|
||||
{refundError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefund}
|
||||
disabled={refundMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refundMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcw size={16} />
|
||||
Confirm Refund
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRefundForm(false);
|
||||
setRefundError(null);
|
||||
setRefundAmount('');
|
||||
}}
|
||||
disabled={refundMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Customer Info */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<User size={16} />
|
||||
Customer
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
{transaction.customer_name && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User size={14} className="text-gray-400" />
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{transaction.customer_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.customer_email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail size={14} className="text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{transaction.customer_email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount Breakdown */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<DollarSign size={16} />
|
||||
Amount Breakdown
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Gross Amount</span>
|
||||
<span className="font-medium">{transaction.amount_display}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Platform Fee</span>
|
||||
<span className="text-red-600">-{transaction.fee_display}</span>
|
||||
</div>
|
||||
{transaction.total_refunded > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Refunded</span>
|
||||
<span className="text-orange-600">
|
||||
-${(transaction.total_refunded / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
|
||||
<span className="font-bold text-green-600">
|
||||
${(transaction.net_amount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
{transaction.payment_method_info && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CreditCard size={16} />
|
||||
Payment Method
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
{getPaymentMethodDisplay()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{transaction.description && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Receipt size={16} />
|
||||
Description
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refund History */}
|
||||
{transaction.refunds && transaction.refunds.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<RefreshCcw size={16} />
|
||||
Refund History
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{transaction.refunds.map((refund: RefundInfo) => (
|
||||
<div
|
||||
key={refund.id}
|
||||
className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-orange-800">{refund.amount_display}</p>
|
||||
<p className="text-sm text-orange-600">
|
||||
{refund.reason
|
||||
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: 'No reason provided'}
|
||||
</p>
|
||||
<p className="text-xs text-orange-500 mt-1">
|
||||
{formatRefundDate(refund.created)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
refund.status === 'succeeded'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{refund.status === 'succeeded' ? (
|
||||
<CheckCircle size={12} />
|
||||
) : (
|
||||
<Clock size={12} />
|
||||
)}
|
||||
{refund.status}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono">{refund.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
Timeline
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Created</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.updated_at !== transaction.created_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Last Updated</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowLeftRight size={16} />
|
||||
Technical Details
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Payment Intent</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_payment_intent_id}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.stripe_charge_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Charge ID</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_charge_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Transaction ID</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Currency</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 uppercase">
|
||||
{transaction.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionDetailModal;
|
||||
92
frontend/src/components/TrialBanner.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock, X, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { Business } from '../types';
|
||||
|
||||
interface TrialBannerProps {
|
||||
business: Business;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrialBanner Component
|
||||
* Shows at the top of the business layout when trial is active
|
||||
* Displays days remaining and upgrade CTA
|
||||
* Dismissible but reappears on page reload
|
||||
*/
|
||||
const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isDismissed || !business.isTrialActive || !business.daysLeftInTrial) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysLeft = business.daysLeftInTrial;
|
||||
const isUrgent = daysLeft <= 3;
|
||||
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigate('/upgrade');
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${
|
||||
isUrgent
|
||||
? 'bg-gradient-to-r from-red-500 to-orange-500'
|
||||
: 'bg-gradient-to-r from-blue-600 to-blue-500'
|
||||
} text-white shadow-md`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Left: Trial Info */}
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`p-2 rounded-full ${isUrgent ? 'bg-white/20' : 'bg-white/20'} backdrop-blur-sm`}>
|
||||
{isUrgent ? (
|
||||
<Clock size={20} className="animate-pulse" />
|
||||
) : (
|
||||
<Sparkles size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm sm:text-base">
|
||||
{t('trial.banner.title')} - {t('trial.banner.daysLeft', { days: daysLeft })}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-white/90 hidden sm:block">
|
||||
{t('trial.banner.expiresOn', { date: trialEndDate })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: CTA Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
className="group px-4 py-2 bg-white text-blue-600 hover:bg-blue-50 rounded-lg font-semibold text-sm transition-all shadow-lg hover:shadow-xl flex items-center gap-2"
|
||||
>
|
||||
{t('trial.banner.upgradeNow')}
|
||||
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||
aria-label={t('trial.banner.dismiss')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialBanner;
|
||||
150
frontend/src/components/UserProfileDropdown.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { User, Settings, LogOut, ChevronDown } from 'lucide-react';
|
||||
import { User as UserType } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
|
||||
interface UserProfileDropdownProps {
|
||||
user: UserType;
|
||||
variant?: 'default' | 'light'; // 'light' for colored headers
|
||||
}
|
||||
|
||||
const UserProfileDropdown: React.FC<UserProfileDropdownProps> = ({ user, variant = 'default' }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||
const location = useLocation();
|
||||
|
||||
// Determine the profile route based on current path
|
||||
const isPlatform = location.pathname.startsWith('/platform');
|
||||
const profilePath = isPlatform ? '/platform/profile' : '/profile';
|
||||
|
||||
const isLight = variant === 'light';
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Close dropdown on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, []);
|
||||
|
||||
const handleSignOut = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
// Get user initials for fallback avatar
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// Format role for display
|
||||
const formatRole = (role: string) => {
|
||||
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center gap-3 pl-6 border-l hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ${
|
||||
isLight
|
||||
? 'border-white/20 focus:ring-white/50'
|
||||
: 'border-gray-200 dark:border-gray-700 focus:ring-brand-500'
|
||||
}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className={`text-sm font-medium ${isLight ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
|
||||
{user.name}
|
||||
</p>
|
||||
<p className={`text-xs ${isLight ? 'text-white/70' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{formatRole(user.role)}
|
||||
</p>
|
||||
</div>
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.name}
|
||||
className={`w-10 h-10 rounded-full object-cover ${
|
||||
isLight ? 'border-2 border-white/30' : 'border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isLight
|
||||
? 'border-2 border-white/30 bg-white/20 text-white'
|
||||
: 'border border-gray-200 dark:border-gray-600 bg-brand-500 text-white'
|
||||
}`}>
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? 'rotate-180' : ''} ${
|
||||
isLight ? 'text-white/70' : 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||
{/* User Info Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1">
|
||||
<Link
|
||||
to={profilePath}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Settings size={16} className="text-gray-400" />
|
||||
Profile Settings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Sign Out */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={isLoggingOut}
|
||||
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfileDropdown;
|
||||
75
frontend/src/components/marketing/CTASection.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface CTASectionProps {
|
||||
variant?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
const CTASection: React.FC<CTASectionProps> = ({ variant = 'default' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<section className="py-16 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.cta.ready')}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t('marketing.cta.readySubtitle')}
|
||||
</p>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('marketing.cta.startFree')}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-20 lg:py-28 bg-gradient-to-br from-brand-600 to-brand-700 relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
{t('marketing.cta.ready')}
|
||||
</h2>
|
||||
<p className="text-lg sm:text-xl text-brand-100 mb-10 max-w-2xl mx-auto">
|
||||
{t('marketing.cta.readySubtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/signup"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 shadow-lg shadow-black/10 transition-colors"
|
||||
>
|
||||
{t('marketing.cta.startFree')}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 text-base font-semibold text-white bg-white/10 rounded-xl hover:bg-white/20 border border-white/20 transition-colors"
|
||||
>
|
||||
{t('marketing.cta.talkToSales')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-brand-200">
|
||||
{t('marketing.cta.noCredit')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CTASection;
|
||||
56
frontend/src/components/marketing/FAQAccordion.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface FAQAccordionProps {
|
||||
items: FAQItem[];
|
||||
}
|
||||
|
||||
const FAQAccordion: React.FC<FAQAccordionProps> = ({ items }) => {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleItem(index)}
|
||||
className="w-full flex items-center justify-between p-6 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
aria-expanded={openIndex === index}
|
||||
>
|
||||
<span className="text-base font-medium text-gray-900 dark:text-white dark:hover:text-white pr-4">
|
||||
{item.question}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-gray-500 dark:text-gray-400 flex-shrink-0 transition-transform duration-200 ${
|
||||
openIndex === index ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-200 ${
|
||||
openIndex === index ? 'max-h-96' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 pt-2 pb-6 text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{item.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQAccordion;
|
||||
41
frontend/src/components/marketing/FeatureCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
iconColor = 'brand',
|
||||
}) => {
|
||||
const colorClasses: Record<string, string> = {
|
||||
brand: 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400',
|
||||
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400',
|
||||
pink: 'bg-pink-100 dark:bg-pink-900/30 text-pink-600 dark:text-pink-400',
|
||||
cyan: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-lg hover:shadow-brand-600/5 transition-all duration-300">
|
||||
<div className={`inline-flex p-3 rounded-xl ${colorClasses[iconColor]} mb-4`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureCard;
|
||||
136
frontend/src/components/marketing/Footer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Twitter, Linkedin, Github, Youtube } from 'lucide-react';
|
||||
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ to: '/features', label: t('marketing.nav.features') },
|
||||
{ to: '/pricing', label: t('marketing.nav.pricing') },
|
||||
{ to: '/signup', label: t('marketing.nav.getStarted') },
|
||||
],
|
||||
company: [
|
||||
{ to: '/about', label: t('marketing.nav.about') },
|
||||
{ to: '/contact', label: t('marketing.nav.contact') },
|
||||
],
|
||||
legal: [
|
||||
{ to: '/privacy', label: t('marketing.footer.legal.privacy') },
|
||||
{ to: '/terms', label: t('marketing.footer.legal.terms') },
|
||||
],
|
||||
};
|
||||
|
||||
const socialLinks = [
|
||||
{ href: 'https://twitter.com/smoothschedule', icon: Twitter, label: 'Twitter' },
|
||||
{ href: 'https://linkedin.com/company/smoothschedule', icon: Linkedin, label: 'LinkedIn' },
|
||||
{ href: 'https://github.com/smoothschedule', icon: Github, label: 'GitHub' },
|
||||
{ href: 'https://youtube.com/@smoothschedule', icon: Youtube, label: 'YouTube' },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 lg:py-16">
|
||||
{/* Main Footer Content */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 lg:gap-12">
|
||||
{/* Brand Column */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<Link to="/" className="flex items-center gap-2 mb-4 group">
|
||||
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Smooth Schedule
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
{t('marketing.description')}
|
||||
</p>
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-brand-600 dark:text-gray-400 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<social.icon className="h-5 w-5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
|
||||
{t('marketing.footer.product.title')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company Links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
|
||||
{t('marketing.footer.company.title')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal Links */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
|
||||
{t('marketing.footer.legal.title')}
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link
|
||||
to={link.to}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200 dark:border-gray-800">
|
||||
<p className="text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
© {currentYear} {t('marketing.footer.copyright')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
166
frontend/src/components/marketing/Hero.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
|
||||
const Hero: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-brand-100 dark:bg-brand-500/10 rounded-full blur-3xl opacity-50 dark:opacity-30" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 lg:py-32">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||
{/* Left Content */}
|
||||
<div className="text-center lg:text-left">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-50 dark:bg-brand-900/30 border border-brand-200 dark:border-brand-800 mb-6">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">
|
||||
{t('marketing.pricing.startToday')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-white leading-tight mb-6">
|
||||
{t('marketing.hero.headline')}
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-xl mx-auto lg:mx-0">
|
||||
{t('marketing.hero.subheadline')}
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 justify-center lg:justify-start mb-8">
|
||||
<Link
|
||||
to="/signup"
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-white bg-brand-600 rounded-xl hover:bg-brand-700 shadow-lg shadow-brand-600/25 hover:shadow-brand-600/40 transition-all duration-200"
|
||||
>
|
||||
{t('marketing.hero.cta')}
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {/* TODO: Open demo modal/video */}}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 text-base font-semibold text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<Play className="h-5 w-5" />
|
||||
{t('marketing.hero.secondaryCta')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 text-sm text-gray-500 dark:text-gray-400 justify-center lg:justify-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span>{t('marketing.pricing.noCredit')}</span>
|
||||
</div>
|
||||
<div className="hidden sm:block w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span>{t('marketing.pricing.startToday')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Dashboard Preview */}
|
||||
<div className="relative">
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-2xl shadow-brand-600/10 border border-gray-200 dark:border-gray-700">
|
||||
{/* Mock Dashboard */}
|
||||
<div className="bg-white dark:bg-gray-800 aspect-[4/3]">
|
||||
{/* Mock Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="inline-block px-4 py-1 rounded-lg bg-gray-100 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400">
|
||||
dashboard.smoothschedule.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mock Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Today', value: '12', color: 'brand' },
|
||||
{ label: 'This Week', value: '48', color: 'green' },
|
||||
{ label: 'Revenue', value: '$2.4k', color: 'purple' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{stat.label}</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Mock */}
|
||||
<div className="rounded-lg bg-gray-50 dark:bg-gray-700/50 p-3">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Today's Schedule</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ time: '9:00 AM', title: 'Sarah J. - Haircut', color: 'brand' },
|
||||
{ time: '10:30 AM', title: 'Mike T. - Consultation', color: 'green' },
|
||||
{ time: '2:00 PM', title: 'Emma W. - Color', color: 'purple' },
|
||||
].map((apt, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-white dark:bg-gray-800">
|
||||
<div className={`w-1 h-8 rounded-full ${
|
||||
apt.color === 'brand' ? 'bg-brand-500' :
|
||||
apt.color === 'green' ? 'bg-green-500' : 'bg-purple-500'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{apt.time}</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{apt.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Elements */}
|
||||
<div className="absolute -bottom-4 -left-4 px-4 py-3 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">New Booking!</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Just now</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Badge */}
|
||||
<div className="mt-16 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t('marketing.hero.trustedBy')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-8 opacity-50">
|
||||
{/* Mock company logos - replace with actual logos */}
|
||||
{['TechCorp', 'Innovate', 'StartupX', 'GrowthCo', 'ScaleUp'].map((name) => (
|
||||
<div key={name} className="text-lg font-bold text-gray-400 dark:text-gray-500">
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
102
frontend/src/components/marketing/HowItWorks.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserPlus, Settings, Rocket } from 'lucide-react';
|
||||
|
||||
const HowItWorks: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: '01',
|
||||
icon: UserPlus,
|
||||
title: t('marketing.howItWorks.step1.title'),
|
||||
description: t('marketing.howItWorks.step1.description'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
icon: Settings,
|
||||
title: t('marketing.howItWorks.step2.title'),
|
||||
description: t('marketing.howItWorks.step2.description'),
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
icon: Rocket,
|
||||
title: t('marketing.howItWorks.step3.title'),
|
||||
description: t('marketing.howItWorks.step3.description'),
|
||||
color: 'green',
|
||||
},
|
||||
];
|
||||
|
||||
const colorClasses: Record<string, { bg: string; text: string; border: string }> = {
|
||||
brand: {
|
||||
bg: 'bg-brand-100 dark:bg-brand-900/30',
|
||||
text: 'text-brand-600 dark:text-brand-400',
|
||||
border: 'border-brand-200 dark:border-brand-800',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-100 dark:bg-green-900/30',
|
||||
text: 'text-green-600 dark:text-green-400',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 lg:py-28 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('marketing.howItWorks.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{t('marketing.howItWorks.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="grid md:grid-cols-3 gap-8 lg:gap-12">
|
||||
{steps.map((step, index) => {
|
||||
const colors = colorClasses[step.color];
|
||||
return (
|
||||
<div key={step.number} className="relative">
|
||||
{/* Connector Line (hidden on mobile) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="hidden md:block absolute top-16 left-1/2 w-full h-0.5 bg-gradient-to-r from-gray-200 dark:from-gray-700 to-transparent" />
|
||||
)}
|
||||
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-center">
|
||||
{/* Step Number */}
|
||||
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full ${colors.bg} ${colors.text} border ${colors.border} text-sm font-bold`}>
|
||||
{step.number}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`inline-flex p-4 rounded-2xl ${colors.bg} mb-6`}>
|
||||
<step.icon className={`h-8 w-8 ${colors.text}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HowItWorks;
|
||||
164
frontend/src/components/marketing/Navbar.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
||||
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
||||
import LanguageSelector from '../LanguageSelector';
|
||||
|
||||
interface NavbarProps {
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/features', label: t('marketing.nav.features') },
|
||||
{ to: '/pricing', label: t('marketing.nav.pricing') },
|
||||
{ to: '/about', label: t('marketing.nav.about') },
|
||||
{ to: '/contact', label: t('marketing.nav.contact') },
|
||||
];
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg shadow-sm'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16 lg:h-20">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">
|
||||
Smooth Schedule
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActive(link.to)
|
||||
? 'text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Selector - Hidden on mobile */}
|
||||
<div className="hidden md:block">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* Login Button - Hidden on mobile */}
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
|
||||
{/* Get Started CTA */}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="hidden sm:inline-flex px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors shadow-sm"
|
||||
>
|
||||
{t('marketing.nav.getStarted')}
|
||||
</Link>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`lg:hidden overflow-hidden transition-all duration-300 ${
|
||||
isMenuOpen ? 'max-h-96' : 'max-h-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 py-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive(link.to)
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-600 dark:text-brand-400'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<hr className="my-2 border-gray-200 dark:border-gray-800" />
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/signup"
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.getStarted')}
|
||||
</Link>
|
||||
<div className="px-4 py-2">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
185
frontend/src/components/marketing/PricingCard.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface PricingCardProps {
|
||||
tier: 'free' | 'professional' | 'business' | 'enterprise';
|
||||
highlighted?: boolean;
|
||||
billingPeriod: 'monthly' | 'annual';
|
||||
}
|
||||
|
||||
const PricingCard: React.FC<PricingCardProps> = ({
|
||||
tier,
|
||||
highlighted = false,
|
||||
billingPeriod,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tierData = {
|
||||
free: {
|
||||
price: 0,
|
||||
annualPrice: 0,
|
||||
},
|
||||
professional: {
|
||||
price: 29,
|
||||
annualPrice: 290,
|
||||
},
|
||||
business: {
|
||||
price: 79,
|
||||
annualPrice: 790,
|
||||
},
|
||||
enterprise: {
|
||||
price: 'custom',
|
||||
annualPrice: 'custom',
|
||||
},
|
||||
};
|
||||
|
||||
const data = tierData[tier];
|
||||
const price = billingPeriod === 'annual' ? data.annualPrice : data.price;
|
||||
const isCustom = price === 'custom';
|
||||
|
||||
// Get features array from i18n
|
||||
const features = t(`marketing.pricing.tiers.${tier}.features`, { returnObjects: true }) as string[];
|
||||
const transactionFee = t(`marketing.pricing.tiers.${tier}.transactionFee`);
|
||||
const trialInfo = t(`marketing.pricing.tiers.${tier}.trial`);
|
||||
|
||||
if (highlighted) {
|
||||
return (
|
||||
<div className="relative flex flex-col p-8 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20">
|
||||
{/* Most Popular Badge */}
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1.5 bg-brand-500 text-white text-sm font-semibold rounded-full whitespace-nowrap">
|
||||
{t('marketing.pricing.mostPopular')}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold text-white mb-2">
|
||||
{t(`marketing.pricing.tiers.${tier}.name`)}
|
||||
</h3>
|
||||
<p className="text-brand-100">
|
||||
{t(`marketing.pricing.tiers.${tier}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-6">
|
||||
{isCustom ? (
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{t('marketing.pricing.tiers.enterprise.price')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-5xl font-bold text-white">${price}</span>
|
||||
<span className="text-brand-200 ml-2">
|
||||
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{trialInfo && (
|
||||
<div className="mt-2 text-sm text-brand-100">
|
||||
{trialInfo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="flex-1 space-y-3 mb-8">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<Check className="h-5 w-5 text-brand-200 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-white">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-3 pt-2 border-t border-brand-500">
|
||||
<span className="text-brand-200 text-sm">{transactionFee}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
{isCustom ? (
|
||||
<Link
|
||||
to="/contact"
|
||||
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.contactSales')}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/signup"
|
||||
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.getStarted')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col p-8 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t(`marketing.pricing.tiers.${tier}.name`)}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t(`marketing.pricing.tiers.${tier}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-6">
|
||||
{isCustom ? (
|
||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
{t('marketing.pricing.tiers.enterprise.price')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-5xl font-bold text-gray-900 dark:text-white">${price}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">
|
||||
{billingPeriod === 'annual' ? '/year' : t('marketing.pricing.perMonth')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{trialInfo && (
|
||||
<div className="mt-2 text-sm text-brand-600 dark:text-brand-400">
|
||||
{trialInfo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="flex-1 space-y-3 mb-8">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-3">
|
||||
<Check className="h-5 w-5 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-3 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">{transactionFee}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
{isCustom ? (
|
||||
<Link
|
||||
to="/contact"
|
||||
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.contactSales')}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/signup"
|
||||
className="block w-full py-3.5 px-4 text-center text-base font-semibold text-brand-600 bg-brand-50 dark:bg-brand-900/30 rounded-xl hover:bg-brand-100 dark:hover:bg-brand-900/50 transition-colors"
|
||||
>
|
||||
{t('marketing.pricing.getStarted')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCard;
|
||||
65
frontend/src/components/marketing/StatsSection.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Building2, Globe, Clock } from 'lucide-react';
|
||||
|
||||
const StatsSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stats = [
|
||||
{
|
||||
icon: Calendar,
|
||||
value: '1M+',
|
||||
label: t('marketing.stats.appointments'),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
icon: Building2,
|
||||
value: '5,000+',
|
||||
label: t('marketing.stats.businesses'),
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
value: '50+',
|
||||
label: t('marketing.stats.countries'),
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
value: '99.9%',
|
||||
label: t('marketing.stats.uptime'),
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
const colorClasses: Record<string, string> = {
|
||||
brand: 'text-brand-600 dark:text-brand-400',
|
||||
green: 'text-green-600 dark:text-green-400',
|
||||
purple: 'text-purple-600 dark:text-purple-400',
|
||||
orange: 'text-orange-600 dark:text-orange-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="inline-flex p-3 rounded-xl bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<stat.icon className={`h-6 w-6 ${colorClasses[stat.color]}`} />
|
||||
</div>
|
||||
<div className={`text-4xl lg:text-5xl font-bold mb-2 ${colorClasses[stat.color]}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsSection;
|
||||
68
frontend/src/components/marketing/TestimonialCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface TestimonialCardProps {
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
company: string;
|
||||
avatarUrl?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
const TestimonialCard: React.FC<TestimonialCardProps> = ({
|
||||
quote,
|
||||
author,
|
||||
role,
|
||||
company,
|
||||
avatarUrl,
|
||||
rating = 5,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* Stars */}
|
||||
<div className="flex gap-1 mb-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-5 w-5 ${
|
||||
i < rating
|
||||
? 'text-yellow-400 fill-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quote */}
|
||||
<blockquote className="flex-1 text-gray-700 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
"{quote}"
|
||||
</blockquote>
|
||||
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={author}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<span className="text-lg font-semibold text-brand-600 dark:text-brand-400">
|
||||
{author.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{author}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{role} at {company}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestimonialCard;
|
||||
463
frontend/src/components/profile/TwoFactorSetup.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Shield, Copy, Check, Download, AlertTriangle, Smartphone } from 'lucide-react';
|
||||
import { useSetupTOTP, useVerifyTOTP, useDisableTOTP, useRecoveryCodes, useRegenerateRecoveryCodes } from '../../hooks/useProfile';
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
isEnabled: boolean;
|
||||
phoneVerified?: boolean;
|
||||
hasPhone?: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onVerifyPhone?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'intro' | 'qrcode' | 'verify' | 'recovery' | 'complete' | 'disable' | 'view-recovery';
|
||||
|
||||
const TwoFactorSetup: React.FC<TwoFactorSetupProps> = ({ isEnabled, phoneVerified = false, hasPhone = false, onClose, onSuccess, onVerifyPhone }) => {
|
||||
const [step, setStep] = useState<SetupStep>(isEnabled ? 'disable' : 'intro');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
const [copiedCodes, setCopiedCodes] = useState(false);
|
||||
|
||||
const setupTOTP = useSetupTOTP();
|
||||
const verifyTOTP = useVerifyTOTP();
|
||||
const disableTOTP = useDisableTOTP();
|
||||
const recoveryCodes = useRecoveryCodes();
|
||||
const regenerateCodes = useRegenerateRecoveryCodes();
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await setupTOTP.mutateAsync();
|
||||
setStep('qrcode');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to start 2FA setup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError('Please enter a 6-digit code');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const result = await verifyTOTP.mutateAsync(verificationCode);
|
||||
// Store recovery codes from response
|
||||
setStep('recovery');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Invalid verification code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (disableCode.length !== 6) {
|
||||
setError('Please enter a 6-digit code');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
await disableTOTP.mutateAsync(disableCode);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Invalid code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewRecoveryCodes = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await recoveryCodes.refetch();
|
||||
setStep('view-recovery');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load recovery codes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateCodes = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await regenerateCodes.mutateAsync();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to regenerate codes');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, type: 'secret' | 'codes') => {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (type === 'secret') {
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
} else {
|
||||
setCopiedCodes(true);
|
||||
setTimeout(() => setCopiedCodes(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = (codes: string[]) => {
|
||||
const content = `SmoothSchedule Recovery Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${codes.join('\n')}\n\nGenerated: ${new Date().toISOString()}`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'smoothschedule-recovery-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
||||
<Shield size={20} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{isEnabled ? 'Manage Two-Factor Authentication' : 'Set Up Two-Factor Authentication'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center gap-2 text-red-700 dark:text-red-400 text-sm">
|
||||
<AlertTriangle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro Step */}
|
||||
{step === 'intro' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Smartphone size={32} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Secure Your Account
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
Two-factor authentication adds an extra layer of security. You'll need an authenticator app like Google Authenticator or Authy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SMS Backup Info */}
|
||||
<div className={`p-4 rounded-lg border ${phoneVerified ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' : 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{phoneVerified ? (
|
||||
<Check size={18} className="text-green-600 dark:text-green-400 mt-0.5" />
|
||||
) : (
|
||||
<AlertTriangle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${phoneVerified ? 'text-green-700 dark:text-green-300' : 'text-amber-700 dark:text-amber-300'}`}>
|
||||
SMS Backup {phoneVerified ? 'Available' : 'Not Available'}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${phoneVerified ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{phoneVerified
|
||||
? 'Your verified phone can be used as a backup method.'
|
||||
: hasPhone
|
||||
? 'Your phone number is not verified. Verify it to enable SMS backup as a fallback when you can\'t access your authenticator app.'
|
||||
: 'Add and verify a phone number in your profile settings to receive text message codes as a backup when you can\'t access your authenticator app.'}
|
||||
</p>
|
||||
{!phoneVerified && hasPhone && onVerifyPhone && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onVerifyPhone();
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
|
||||
>
|
||||
Verify your phone number now
|
||||
</button>
|
||||
)}
|
||||
{!phoneVerified && !hasPhone && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 underline hover:no-underline"
|
||||
>
|
||||
Go to profile settings to add a phone number
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartSetup}
|
||||
disabled={setupTOTP.isPending}
|
||||
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{setupTOTP.isPending ? 'Setting up...' : 'Get Started'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Step */}
|
||||
{step === 'qrcode' && setupTOTP.data && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Scan this QR code with your authenticator app
|
||||
</p>
|
||||
<div className="bg-white p-4 rounded-lg inline-block mb-4">
|
||||
<img
|
||||
src={`data:image/png;base64,${setupTOTP.data.qr_code}`}
|
||||
alt="2FA QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Can't scan? Enter this code manually:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-600 text-sm font-mono text-gray-900 dark:text-white break-all">
|
||||
{setupTOTP.data.secret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(setupTOTP.data!.secret, 'secret')}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedSecret ? <Check size={18} className="text-green-500" /> : <Copy size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep('verify')}
|
||||
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verify Step */}
|
||||
{step === 'verify' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full text-center text-2xl tracking-widest py-4 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('qrcode')}
|
||||
className="flex-1 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={verifyTOTP.isPending || verificationCode.length !== 6}
|
||||
className="flex-1 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{verifyTOTP.isPending ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recovery Codes Step */}
|
||||
{step === 'recovery' && verifyTOTP.data?.recovery_codes && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Check size={24} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
2FA Enabled Successfully!
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Save these recovery codes in a safe place
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<AlertTriangle size={16} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Each code can only be used once. Store them securely - you won't see them again!
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
|
||||
{verifyTOTP.data.recovery_codes.map((code: string, index: number) => (
|
||||
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyToClipboard(verifyTOTP.data!.recovery_codes.join('\n'), 'codes')}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
{copiedCodes ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadRecoveryCodes(verifyTOTP.data!.recovery_codes)}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="w-full py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complete Step (fallback) */}
|
||||
{step === 'complete' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Check size={32} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Two-Factor Authentication Enabled
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
|
||||
Your account is now more secure
|
||||
</p>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="px-6 py-3 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable Step */}
|
||||
{step === 'disable' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleViewRecoveryCodes}
|
||||
disabled={recoveryCodes.isFetching}
|
||||
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="font-medium text-gray-900 dark:text-white">View Recovery Codes</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{recoveryCodes.isFetching ? 'Loading...' : '→'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
To disable 2FA, enter a code from your authenticator app:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full text-center text-xl tracking-widest py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-transparent font-mono mb-3"
|
||||
/>
|
||||
<button
|
||||
onClick={handleDisable}
|
||||
disabled={disableTOTP.isPending || disableCode.length !== 6}
|
||||
className="w-full py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{disableTOTP.isPending ? 'Disabling...' : 'Disable Two-Factor Authentication'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Recovery Codes Step */}
|
||||
{step === 'view-recovery' && recoveryCodes.data && (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setStep('disable')}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Your recovery codes (each can only be used once):
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 bg-white dark:bg-gray-800 rounded-lg p-3">
|
||||
{recoveryCodes.data.map((code: string, index: number) => (
|
||||
<code key={index} className="text-sm font-mono text-gray-900 dark:text-white">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyToClipboard(recoveryCodes.data!.join('\n'), 'codes')}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{copiedCodes ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
{copiedCodes ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadRecoveryCodes(recoveryCodes.data!)}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Download size={16} />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRegenerateCodes}
|
||||
disabled={regenerateCodes.isPending}
|
||||
className="w-full py-2 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{regenerateCodes.isPending ? 'Regenerating...' : 'Regenerate Recovery Codes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorSetup;
|
||||
332
frontend/src/hooks/useAppointmentWebSocket.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* WebSocket hook for real-time appointment updates.
|
||||
* Connects to the backend WebSocket and updates React Query cache.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
import { getSubdomain } from '../api/config';
|
||||
import { Appointment } from '../types';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong';
|
||||
appointment?: {
|
||||
id: string;
|
||||
business_id: string;
|
||||
service_id: string;
|
||||
resource_id: string | null;
|
||||
customer_id: string;
|
||||
customer_name: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
duration_minutes: number;
|
||||
status: string;
|
||||
notes: string;
|
||||
};
|
||||
appointment_id?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface UseAppointmentWebSocketOptions {
|
||||
enabled?: boolean;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
onError?: (error: Event) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform backend appointment format to frontend format
|
||||
*/
|
||||
function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null {
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
resourceId: data.resource_id,
|
||||
customerId: data.customer_id,
|
||||
customerName: data.customer_name,
|
||||
serviceId: data.service_id,
|
||||
startTime: new Date(data.start_time),
|
||||
durationMinutes: data.duration_minutes,
|
||||
status: data.status as Appointment['status'],
|
||||
notes: data.notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for real-time appointment updates via WebSocket.
|
||||
* Handles React StrictMode's double-effect invocation gracefully.
|
||||
*/
|
||||
export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) {
|
||||
const { enabled = true, onConnected, onDisconnected, onError } = options;
|
||||
const queryClient = useQueryClient();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const isCleaningUpRef = useRef(false);
|
||||
const maxReconnectAttempts = 5;
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
// Store callbacks in refs to avoid effect re-runs
|
||||
const onConnectedRef = useRef(onConnected);
|
||||
const onDisconnectedRef = useRef(onDisconnected);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
useEffect(() => {
|
||||
onConnectedRef.current = onConnected;
|
||||
onDisconnectedRef.current = onDisconnected;
|
||||
onErrorRef.current = onError;
|
||||
}, [onConnected, onDisconnected, onError]);
|
||||
|
||||
// Get WebSocket URL - not a callback to avoid recreating
|
||||
const getWebSocketUrl = () => {
|
||||
const token = getCookie('access_token');
|
||||
const subdomain = getSubdomain();
|
||||
|
||||
if (!token || !subdomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine WebSocket host - use api subdomain for WebSocket
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHost = `api.lvh.me:8000`; // In production, this would come from config
|
||||
|
||||
return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`;
|
||||
};
|
||||
|
||||
const updateQueryCache = useCallback((message: WebSocketMessage) => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] });
|
||||
|
||||
appointmentQueries.forEach((query) => {
|
||||
queryClient.setQueryData<Appointment[]>(query.queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
|
||||
switch (message.type) {
|
||||
case 'appointment_created': {
|
||||
const newAppointment = transformAppointment(message.appointment);
|
||||
if (!newAppointment) return old;
|
||||
// Check if appointment already exists (avoid duplicates)
|
||||
if (old.some(apt => apt.id === newAppointment.id)) {
|
||||
return old;
|
||||
}
|
||||
return [...old, newAppointment];
|
||||
}
|
||||
|
||||
case 'appointment_updated': {
|
||||
const updatedAppointment = transformAppointment(message.appointment);
|
||||
if (!updatedAppointment) return old;
|
||||
return old.map(apt =>
|
||||
apt.id === updatedAppointment.id ? updatedAppointment : apt
|
||||
);
|
||||
}
|
||||
|
||||
case 'appointment_deleted': {
|
||||
if (!message.appointment_id) return old;
|
||||
return old.filter(apt => apt.id !== message.appointment_id);
|
||||
}
|
||||
|
||||
default:
|
||||
return old;
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
// Main effect to manage WebSocket connection
|
||||
// Only depends on `enabled` - other values are read from refs or called as functions
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset cleanup flag at start of effect
|
||||
isCleaningUpRef.current = false;
|
||||
|
||||
// Track the current effect's abort controller to handle StrictMode
|
||||
let effectAborted = false;
|
||||
|
||||
const connect = () => {
|
||||
// Don't connect if effect was aborted or we're cleaning up
|
||||
if (effectAborted || isCleaningUpRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getWebSocketUrl();
|
||||
if (!url) {
|
||||
console.log('WebSocket: Missing token or subdomain, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close existing connection if any
|
||||
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***'));
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Don't process if effect was aborted or cleaning up
|
||||
if (effectAborted || isCleaningUpRef.current) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('WebSocket: Connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setIsConnected(true);
|
||||
onConnectedRef.current?.();
|
||||
|
||||
// Start ping interval to keep connection alive
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN && !effectAborted) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Ignore messages if effect was aborted
|
||||
if (effectAborted) return;
|
||||
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'connection_established':
|
||||
console.log('WebSocket: Connection confirmed -', message.message);
|
||||
break;
|
||||
case 'pong':
|
||||
// Heartbeat response, ignore
|
||||
break;
|
||||
case 'appointment_created':
|
||||
case 'appointment_updated':
|
||||
case 'appointment_deleted':
|
||||
console.log('WebSocket: Received', message.type);
|
||||
updateQueryCache(message);
|
||||
break;
|
||||
default:
|
||||
console.log('WebSocket: Unknown message type', message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WebSocket: Failed to parse message', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
// Only log error if not aborted (StrictMode cleanup causes expected errors)
|
||||
if (!effectAborted) {
|
||||
console.error('WebSocket: Error', error);
|
||||
onErrorRef.current?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// Don't log or handle if effect was aborted (expected during StrictMode)
|
||||
if (effectAborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('WebSocket: Disconnected', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
onDisconnectedRef.current?.();
|
||||
|
||||
// Clear ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Only attempt reconnection if not cleaning up
|
||||
if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
effectAborted = true;
|
||||
isCleaningUpRef.current = true;
|
||||
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [enabled]); // Only re-run when enabled changes
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
isCleaningUpRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Close existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
// Connection will be re-established by the effect when we force re-render
|
||||
// For now, we'll rely on the onclose handler to trigger reconnection
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
isCleaningUpRef.current = true;
|
||||
|
||||
// Clear reconnect timeout
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear ping interval
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
reconnect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||