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>
This commit is contained in:
poduck
2025-11-27 01:43:20 -05:00
commit 2e111364a2
567 changed files with 96410 additions and 0 deletions

180
PLATFORM_INTEGRATION.md Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
# Core app initialization
default_app_config = 'core.apps.CoreConfig'

237
core/admin.py Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

View File

@@ -0,0 +1,2 @@
VITE_DEV_MODE=true
VITE_API_URL=http://lvh.me:8000

10
frontend/.env.example Normal file
View 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
View 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
View 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
View 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)

View 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
View 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
View 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
View 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.

View 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.

View 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();
})();

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

50
frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

File diff suppressed because one or more lines are too long

View 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,
},
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

1
frontend/public/vite.svg Normal file
View 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
View 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
View 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
View 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;
};

View 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;
};

View 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;

View 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';
};

View 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
View 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
View 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}/`);
};

View 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 || {});

View 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;
};

View 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
View 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/`);
};

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View 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

View 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;
}

View 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;

View 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;
}

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;

File diff suppressed because one or more lines are too long

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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">
&copy; {currentYear} {t('marketing.footer.copyright')}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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,
};
}

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