Compare commits
26 Commits
8dc2248f1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7733449dd | ||
|
|
29bcb27e76 | ||
|
|
41caccd31a | ||
|
|
aa9d920612 | ||
|
|
b384d9912a | ||
|
|
d25c578e59 | ||
|
|
a8c271b5e3 | ||
|
|
6afa3d7415 | ||
|
|
17786c5ec0 | ||
|
|
4a66246708 | ||
|
|
76c0d71aa0 | ||
|
|
384fe0fd86 | ||
|
|
4afcaa2b0d | ||
|
|
8c52d6a275 | ||
|
|
18c9a69d75 | ||
|
|
30ec150d90 | ||
|
|
ba2c656243 | ||
|
|
485f86086b | ||
|
|
2f6ea82114 | ||
|
|
507222316c | ||
|
|
c5c108c76f | ||
|
|
90fa628cb5 | ||
|
|
7f389830f8 | ||
|
|
30909f3268 | ||
|
|
df45a6f5d7 | ||
|
|
156ad09232 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)",
|
||||
"WebSearch"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
163
CLAUDE.md
163
CLAUDE.md
@@ -21,6 +21,169 @@
|
||||
|
||||
Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work.
|
||||
|
||||
## CRITICAL: Test-Driven Development (TDD) Required
|
||||
|
||||
**All code changes MUST follow TDD.** This is non-negotiable.
|
||||
|
||||
### TDD Workflow
|
||||
|
||||
1. **Write tests FIRST** before writing any implementation code
|
||||
2. **Run tests** to verify they fail (red)
|
||||
3. **Write minimal code** to make tests pass (green)
|
||||
4. **Refactor** while keeping tests green
|
||||
5. **Repeat** for each new feature or bug fix
|
||||
|
||||
### Coverage Requirements
|
||||
|
||||
| Target | Minimum | Goal |
|
||||
|--------|---------|------|
|
||||
| Backend (Django) | **80%** | 100% |
|
||||
| Frontend (React) | **80%** | 100% |
|
||||
|
||||
### Running Tests with Coverage
|
||||
|
||||
**Backend (Django):**
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
|
||||
# Run all tests with coverage
|
||||
docker compose -f docker-compose.local.yml exec django pytest --cov --cov-report=term-missing
|
||||
|
||||
# Run tests for a specific app
|
||||
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/ --cov=smoothschedule/scheduling/schedule
|
||||
|
||||
# Run a single test file
|
||||
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/path/to/test_file.py -v
|
||||
|
||||
# Run tests matching a pattern
|
||||
docker compose -f docker-compose.local.yml exec django pytest -k "test_create_resource" -v
|
||||
```
|
||||
|
||||
**Frontend (React):**
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/frontend
|
||||
|
||||
# Run all tests with coverage
|
||||
npm test -- --coverage
|
||||
|
||||
# Run tests in watch mode during development
|
||||
npm test
|
||||
|
||||
# Run a single test file
|
||||
npm test -- src/hooks/__tests__/useResources.test.ts
|
||||
|
||||
# Run tests matching a pattern
|
||||
npm test -- -t "should create resource"
|
||||
```
|
||||
|
||||
### Test File Organization
|
||||
|
||||
**Backend:**
|
||||
```
|
||||
smoothschedule/smoothschedule/{domain}/{app}/
|
||||
├── models.py
|
||||
├── views.py
|
||||
├── serializers.py
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_models.py # Model unit tests
|
||||
├── test_serializers.py # Serializer tests
|
||||
├── test_views.py # API endpoint tests
|
||||
└── factories.py # Test factories (optional)
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```
|
||||
frontend/src/
|
||||
├── hooks/
|
||||
│ ├── useResources.ts
|
||||
│ └── __tests__/
|
||||
│ └── useResources.test.ts
|
||||
├── components/
|
||||
│ ├── MyComponent.tsx
|
||||
│ └── __tests__/
|
||||
│ └── MyComponent.test.tsx
|
||||
└── pages/
|
||||
├── MyPage.tsx
|
||||
└── __tests__/
|
||||
└── MyPage.test.tsx
|
||||
```
|
||||
|
||||
### What to Test
|
||||
|
||||
**Backend:**
|
||||
- Model methods and properties
|
||||
- Model validation (clean methods)
|
||||
- Serializer validation
|
||||
- API endpoints (all HTTP methods)
|
||||
- Permission classes
|
||||
- Custom querysets and managers
|
||||
- Signals
|
||||
- Celery tasks
|
||||
- Utility functions
|
||||
|
||||
**Frontend:**
|
||||
- Custom hooks (state changes, API calls)
|
||||
- Component rendering
|
||||
- User interactions (clicks, form submissions)
|
||||
- Conditional rendering
|
||||
- Error states
|
||||
- Loading states
|
||||
- API client functions
|
||||
|
||||
### TDD Example - Adding a New Feature
|
||||
|
||||
**Step 1: Write the test first**
|
||||
```python
|
||||
# Backend: test_views.py
|
||||
def test_create_resource_with_schedule(self, api_client, tenant):
|
||||
"""New feature: resources can have a default schedule."""
|
||||
data = {
|
||||
"name": "Test Resource",
|
||||
"type": "STAFF",
|
||||
"default_schedule": {
|
||||
"monday": {"start": "09:00", "end": "17:00"},
|
||||
"tuesday": {"start": "09:00", "end": "17:00"},
|
||||
}
|
||||
}
|
||||
response = api_client.post("/api/resources/", data, format="json")
|
||||
assert response.status_code == 201
|
||||
assert response.data["default_schedule"]["monday"]["start"] == "09:00"
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Frontend: useResources.test.ts
|
||||
it('should create resource with schedule', async () => {
|
||||
const { result } = renderHook(() => useCreateResource());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
name: 'Test Resource',
|
||||
type: 'STAFF',
|
||||
defaultSchedule: { monday: { start: '09:00', end: '17:00' } }
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockApiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
|
||||
default_schedule: expect.any(Object)
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests - they should FAIL**
|
||||
|
||||
**Step 3: Write minimal implementation to make tests pass**
|
||||
|
||||
**Step 4: Refactor if needed while keeping tests green**
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
Before committing ANY code:
|
||||
1. [ ] Tests written BEFORE implementation
|
||||
2. [ ] All tests pass
|
||||
3. [ ] Coverage meets minimum threshold (80%)
|
||||
4. [ ] No skipped or disabled tests without justification
|
||||
|
||||
## CRITICAL: Backend Runs in Docker
|
||||
|
||||
**NEVER run Django commands directly.** Always use Docker Compose:
|
||||
|
||||
655
README.md
655
README.md
@@ -1,257 +1,470 @@
|
||||
# SmoothSchedule - Multi-Tenant Scheduling Platform
|
||||
|
||||
A production-ready multi-tenant SaaS platform for resource scheduling and orchestration.
|
||||
A production-ready multi-tenant SaaS platform for resource scheduling, appointments, and business management.
|
||||
|
||||
## 🎯 Features
|
||||
## Features
|
||||
|
||||
- ✅ **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||
- ✅ **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
|
||||
- ✅ **Modern Stack**: Django 5.2 + React 18 + Vite
|
||||
- ✅ **Docker Ready**: Complete production & development Docker Compose setup
|
||||
- ✅ **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
|
||||
- ✅ **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
|
||||
- ✅ **Task Queue**: Celery + Redis for background jobs
|
||||
- ✅ **Real-time**: Django Channels + WebSockets support
|
||||
- ✅ **Production Ready**: Fully configured for deployment
|
||||
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
|
||||
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
|
||||
- **Real-time Updates**: Django Channels + WebSockets
|
||||
- **Background Tasks**: Celery + Redis
|
||||
- **Auto SSL**: Let's Encrypt certificates via Traefik
|
||||
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
|
||||
- **Docker Ready**: Complete Docker Compose setup for dev and production
|
||||
|
||||
## 📚 Documentation
|
||||
## Project Structure
|
||||
|
||||
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - **Manual step-by-step production deployment** (start here for fresh deployments)
|
||||
- **[QUICK-REFERENCE.md](QUICK-REFERENCE.md)** - Common commands and quick start
|
||||
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
|
||||
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
|
||||
```
|
||||
smoothschedule2/
|
||||
├── frontend/ # React + Vite + TypeScript
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client and hooks
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── hooks/ # React Query hooks
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ └── types.ts # TypeScript interfaces
|
||||
│ ├── nginx.conf # Production nginx config
|
||||
│ └── Dockerfile.prod # Production frontend container
|
||||
│
|
||||
├── smoothschedule/ # Django backend
|
||||
│ ├── config/ # Django settings
|
||||
│ │ └── settings/
|
||||
│ │ ├── base.py # Base settings
|
||||
│ │ ├── local.py # Local development
|
||||
│ │ └── production.py # Production settings
|
||||
│ ├── smoothschedule/ # Django apps (domain-based)
|
||||
│ │ ├── identity/ # Users, tenants, authentication
|
||||
│ │ │ ├── core/ # Tenant, Domain, middleware
|
||||
│ │ │ └── users/ # User model, MFA, auth
|
||||
│ │ ├── scheduling/ # Core scheduling
|
||||
│ │ │ ├── schedule/ # Resources, Events, Services
|
||||
│ │ │ ├── contracts/ # E-signatures
|
||||
│ │ │ └── analytics/ # Business analytics
|
||||
│ │ ├── communication/ # Notifications, SMS, mobile
|
||||
│ │ ├── commerce/ # Payments, tickets
|
||||
│ │ └── platform/ # Admin, public API
|
||||
│ ├── docker-compose.local.yml
|
||||
│ └── docker-compose.production.yml
|
||||
│
|
||||
├── deploy.sh # Automated deployment script
|
||||
└── CLAUDE.md # Development guide
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
---
|
||||
|
||||
### Local Development
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose** (for backend)
|
||||
- **Node.js 22+** and **npm** (for frontend)
|
||||
- **Git**
|
||||
|
||||
### Step 1: Clone the Repository
|
||||
|
||||
```bash
|
||||
# Start backend (Django in Docker)
|
||||
git clone https://github.com/your-repo/smoothschedule.git
|
||||
cd smoothschedule
|
||||
```
|
||||
|
||||
### Step 2: Start the Backend (Django in Docker)
|
||||
|
||||
```bash
|
||||
cd smoothschedule
|
||||
|
||||
# Start all backend services
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# Start frontend (React with Vite)
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# Wait for services to initialize (first time takes longer)
|
||||
sleep 30
|
||||
|
||||
# Access the app
|
||||
# Frontend: http://platform.lvh.me:5173
|
||||
# Backend API: http://lvh.me:8000/api
|
||||
# Run database migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
|
||||
# Create a superuser (optional)
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for detailed development instructions.
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For **fresh deployments or complete reset**, follow [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for manual step-by-step instructions.
|
||||
|
||||
For **routine updates**, use the automated script:
|
||||
### Step 3: Start the Frontend (React with Vite)
|
||||
|
||||
```bash
|
||||
# Deploy to production server (code changes only)
|
||||
./deploy.sh poduck@smoothschedule.com
|
||||
cd ../frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
See [PRODUCTION-READY.md](PRODUCTION-READY.md) for deployment checklist and [DEPLOYMENT.md](DEPLOYMENT.md) for detailed steps.
|
||||
### Step 4: Access the Application
|
||||
|
||||
## 🏗️ Architecture
|
||||
The application uses `lvh.me` (resolves to 127.0.0.1) for subdomain-based multi-tenancy:
|
||||
|
||||
| URL | Purpose |
|
||||
|-----|---------|
|
||||
| http://platform.lvh.me:5173 | Platform admin dashboard |
|
||||
| http://demo.lvh.me:5173 | Demo tenant (if created) |
|
||||
| http://lvh.me:8000/api/ | Backend API |
|
||||
| http://lvh.me:8000/admin/ | Django admin |
|
||||
|
||||
**Why `lvh.me`?** Browsers don't allow cookies with `domain=.localhost`, but `lvh.me` resolves to 127.0.0.1 and allows proper cookie sharing across subdomains.
|
||||
|
||||
### Local Development Commands
|
||||
|
||||
```bash
|
||||
# Backend commands (always use docker compose)
|
||||
cd smoothschedule
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yml logs -f django
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
|
||||
# Django shell
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
|
||||
# Run tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest
|
||||
|
||||
# Stop all services
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
# Frontend commands
|
||||
cd frontend
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Creating a Test Tenant
|
||||
|
||||
```bash
|
||||
cd smoothschedule
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
|
||||
# Create tenant
|
||||
tenant = Tenant.objects.create(
|
||||
name="Demo Business",
|
||||
schema_name="demo",
|
||||
)
|
||||
|
||||
# Create domain
|
||||
Domain.objects.create(
|
||||
domain="demo.lvh.me",
|
||||
tenant=tenant,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
print(f"Created tenant: {tenant.name}")
|
||||
print(f"Access at: http://demo.lvh.me:5173")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Ubuntu/Debian server with Docker and Docker Compose
|
||||
- Domain name with DNS configured:
|
||||
- `A` record: `yourdomain.com` → Server IP
|
||||
- `A` record: `*.yourdomain.com` → Server IP (wildcard)
|
||||
- SSH access to the server
|
||||
|
||||
### Quick Deploy (Existing Server)
|
||||
|
||||
For routine updates to an existing production server:
|
||||
|
||||
```bash
|
||||
# From your local machine
|
||||
./deploy.sh user@yourdomain.com
|
||||
|
||||
# Or deploy specific services
|
||||
./deploy.sh user@yourdomain.com nginx
|
||||
./deploy.sh user@yourdomain.com django
|
||||
```
|
||||
|
||||
### Fresh Server Deployment
|
||||
|
||||
#### Step 1: Server Setup
|
||||
|
||||
SSH into your server and install Docker:
|
||||
|
||||
```bash
|
||||
ssh user@yourdomain.com
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Logout and login for group changes
|
||||
exit
|
||||
ssh user@yourdomain.com
|
||||
```
|
||||
|
||||
#### Step 2: Clone Repository
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/your-repo/smoothschedule.git smoothschedule
|
||||
cd smoothschedule
|
||||
```
|
||||
|
||||
#### Step 3: Configure Environment Variables
|
||||
|
||||
Create production environment files:
|
||||
|
||||
```bash
|
||||
mkdir -p smoothschedule/.envs/.production
|
||||
|
||||
# Django configuration
|
||||
cat > smoothschedule/.envs/.production/.django << 'EOF'
|
||||
DJANGO_SECRET_KEY=your-random-secret-key-here
|
||||
DJANGO_DEBUG=False
|
||||
DJANGO_ALLOWED_HOSTS=yourdomain.com,*.yourdomain.com
|
||||
|
||||
DJANGO_ADMIN_URL=your-secret-admin-path/
|
||||
FRONTEND_URL=https://platform.yourdomain.com
|
||||
PLATFORM_BASE_URL=https://platform.yourdomain.com
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
|
||||
# DigitalOcean Spaces (or S3)
|
||||
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
|
||||
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
DJANGO_AWS_STORAGE_BUCKET_NAME=your-bucket
|
||||
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
|
||||
DJANGO_AWS_S3_REGION_NAME=nyc3
|
||||
|
||||
# SSL
|
||||
DJANGO_SECURE_SSL_REDIRECT=True
|
||||
DJANGO_SESSION_COOKIE_SECURE=True
|
||||
DJANGO_CSRF_COOKIE_SECURE=True
|
||||
|
||||
# Cloudflare (for wildcard SSL)
|
||||
CF_DNS_API_TOKEN=your-cloudflare-api-token
|
||||
EOF
|
||||
|
||||
# PostgreSQL configuration
|
||||
cat > smoothschedule/.envs/.production/.postgres << 'EOF'
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=smoothschedule
|
||||
POSTGRES_USER=smoothschedule_user
|
||||
POSTGRES_PASSWORD=your-secure-database-password
|
||||
EOF
|
||||
```
|
||||
|
||||
#### Step 4: Build and Start
|
||||
|
||||
```bash
|
||||
cd ~/smoothschedule/smoothschedule
|
||||
|
||||
# Build all images
|
||||
docker compose -f docker-compose.production.yml build
|
||||
|
||||
# Start services
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Wait for startup
|
||||
sleep 30
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||
|
||||
# Collect static files
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
||||
|
||||
# Create superuser
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
#### Step 5: Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check all containers are running
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
|
||||
# Test endpoints
|
||||
curl https://yourdomain.com/api/health/
|
||||
```
|
||||
|
||||
### Production URLs
|
||||
|
||||
| URL | Purpose |
|
||||
|-----|---------|
|
||||
| https://yourdomain.com | Marketing site |
|
||||
| https://platform.yourdomain.com | Platform admin |
|
||||
| https://*.yourdomain.com | Tenant subdomains |
|
||||
| https://api.yourdomain.com | API (if configured) |
|
||||
| https://yourdomain.com:5555 | Flower (Celery monitoring) |
|
||||
|
||||
### Production Management Commands
|
||||
|
||||
```bash
|
||||
ssh user@yourdomain.com
|
||||
cd ~/smoothschedule/smoothschedule
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.production.yml logs -f django
|
||||
|
||||
# Restart services
|
||||
docker compose -f docker-compose.production.yml restart
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||
|
||||
# Django shell
|
||||
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||
|
||||
# Database backup
|
||||
docker compose -f docker-compose.production.yml exec postgres pg_dump -U smoothschedule_user smoothschedule > backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 │
|
||||
└─────────────────────────────────────────┘
|
||||
PostgreSQL Database
|
||||
├── public (shared schema)
|
||||
│ ├── Tenants
|
||||
│ ├── Domains
|
||||
│ ├── Users
|
||||
│ └── PermissionGrants
|
||||
├── demo (tenant schema)
|
||||
│ ├── Resources
|
||||
│ ├── Events
|
||||
│ ├── Services
|
||||
│ └── Customers
|
||||
└── acme (tenant schema)
|
||||
├── Resources
|
||||
├── Events
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| Role | Level | Access |
|
||||
|------|-------|--------|
|
||||
| SUPERUSER | Platform | All tenants (god mode) |
|
||||
| PLATFORM_MANAGER | Platform | All tenants |
|
||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
|
||||
| TENANT_OWNER | Tenant | Full tenant access |
|
||||
| TENANT_MANAGER | Tenant | Most tenant features |
|
||||
| TENANT_STAFF | Tenant | Limited tenant access |
|
||||
| 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
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
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
|
||||
Browser → Traefik (SSL) → nginx (frontend) or django (API)
|
||||
↓
|
||||
React SPA
|
||||
↓
|
||||
/api/* → django:5000
|
||||
/ws/* → django:5000 (WebSocket)
|
||||
```
|
||||
|
||||
## 🔐 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**
|
||||
## Configuration Files
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `smoothschedule/docker-compose.local.yml` | Local Docker services |
|
||||
| `smoothschedule/docker-compose.production.yml` | Production Docker services |
|
||||
| `smoothschedule/.envs/.local/` | Local environment variables |
|
||||
| `smoothschedule/.envs/.production/` | Production environment variables |
|
||||
| `smoothschedule/config/settings/` | Django settings |
|
||||
| `smoothschedule/compose/production/traefik/traefik.yml` | Traefik routing config |
|
||||
|
||||
### Frontend
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/.env.development` | Local environment variables |
|
||||
| `frontend/.env.production` | Production environment variables |
|
||||
| `frontend/nginx.conf` | Production nginx config |
|
||||
| `frontend/vite.config.ts` | Vite bundler config |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
|
||||
```bash
|
||||
# Check Docker logs
|
||||
docker compose -f docker-compose.local.yml logs django
|
||||
|
||||
# Common issues:
|
||||
# - Database not ready: wait longer, then restart django
|
||||
# - Missing migrations: run migrate command
|
||||
# - Port conflict: check if 8000 is in use
|
||||
```
|
||||
|
||||
### Frontend can't connect to API
|
||||
|
||||
```bash
|
||||
# Verify backend is running
|
||||
curl http://lvh.me:8000/api/
|
||||
|
||||
# Check CORS settings in Django
|
||||
# Ensure CORS_ALLOWED_ORIGINS includes http://platform.lvh.me:5173
|
||||
```
|
||||
|
||||
### WebSockets disconnecting
|
||||
|
||||
```bash
|
||||
# Check nginx has /ws/ proxy configured
|
||||
# Verify django is running ASGI (Daphne)
|
||||
# Check production traefik/nginx logs
|
||||
```
|
||||
|
||||
### Multi-tenant issues
|
||||
|
||||
```bash
|
||||
# Check tenant exists
|
||||
docker compose exec django python manage.py shell
|
||||
>>> from smoothschedule.identity.core.models import Tenant, Domain
|
||||
>>> Tenant.objects.all()
|
||||
>>> Domain.objects.all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Development guide, coding standards, architecture details
|
||||
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
|
||||
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - Step-by-step manual deployment
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -184,6 +184,8 @@ if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
||||
|
||||
echo ">>> Seeding/updating platform plugins for all tenants..."
|
||||
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
|
||||
import django
|
||||
django.setup()
|
||||
from django_tenants.utils import get_tenant_model
|
||||
from django.core.management import call_command
|
||||
Tenant = get_tenant_model()
|
||||
|
||||
576
docs/SITE_BUILDER_DESIGN.md
Normal file
576
docs/SITE_BUILDER_DESIGN.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# Puck Site Builder - Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the architecture, data model, migration strategy, and security decisions for the SmoothSchedule Puck-based site builder.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Production-quality site builder** - Enable tenants to build unique pages using nested layout primitives, theme tokens, and booking-native blocks
|
||||
2. **Backward compatibility** - Existing pages must continue to render
|
||||
3. **Multi-tenant safety** - Full tenant isolation for all page data
|
||||
4. **Security** - No arbitrary script injection; sanitized embeds only
|
||||
5. **Feature gating** - Hide/disable blocks based on plan without breaking existing content
|
||||
|
||||
## Data Model
|
||||
|
||||
### Current Schema (Existing)
|
||||
|
||||
```
|
||||
Site
|
||||
├── tenant (OneToOne → Tenant)
|
||||
├── primary_domain
|
||||
├── is_enabled
|
||||
├── template_key
|
||||
└── pages[] (Page)
|
||||
|
||||
Page
|
||||
├── site (FK → Site)
|
||||
├── slug
|
||||
├── path
|
||||
├── title
|
||||
├── is_home
|
||||
├── is_published
|
||||
├── order
|
||||
├── puck_data (JSONField - Puck Data payload)
|
||||
└── version (int - for migrations)
|
||||
```
|
||||
|
||||
### New Schema Additions
|
||||
|
||||
#### SiteConfig (New Model)
|
||||
|
||||
Stores global theme tokens and chrome settings. One per Site, not duplicated per page.
|
||||
|
||||
```python
|
||||
class SiteConfig(models.Model):
|
||||
site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name='config')
|
||||
|
||||
# Theme Tokens
|
||||
theme = models.JSONField(default=dict)
|
||||
# Structure:
|
||||
# {
|
||||
# "colors": {
|
||||
# "primary": "#3b82f6",
|
||||
# "secondary": "#64748b",
|
||||
# "accent": "#f59e0b",
|
||||
# "background": "#ffffff",
|
||||
# "surface": "#f8fafc",
|
||||
# "text": "#1e293b",
|
||||
# "textMuted": "#64748b"
|
||||
# },
|
||||
# "typography": {
|
||||
# "fontFamily": "Inter, system-ui, sans-serif",
|
||||
# "headingFontFamily": null, # null = use fontFamily
|
||||
# "baseFontSize": "16px",
|
||||
# "scale": 1.25 # type scale ratio
|
||||
# },
|
||||
# "buttons": {
|
||||
# "borderRadius": "8px",
|
||||
# "primaryStyle": "solid", # solid | outline | ghost
|
||||
# "secondaryStyle": "outline"
|
||||
# },
|
||||
# "sections": {
|
||||
# "containerMaxWidth": "1280px",
|
||||
# "defaultPaddingY": "80px"
|
||||
# }
|
||||
# }
|
||||
|
||||
# Global Chrome
|
||||
header = models.JSONField(default=dict)
|
||||
# Structure:
|
||||
# {
|
||||
# "enabled": true,
|
||||
# "logo": { "src": "", "alt": "", "width": 120 },
|
||||
# "navigation": [
|
||||
# { "label": "Home", "href": "/" },
|
||||
# { "label": "Services", "href": "/services" },
|
||||
# { "label": "Book Now", "href": "/book", "style": "button" }
|
||||
# ],
|
||||
# "sticky": true,
|
||||
# "style": "default" # default | transparent | minimal
|
||||
# }
|
||||
|
||||
footer = models.JSONField(default=dict)
|
||||
# Structure:
|
||||
# {
|
||||
# "enabled": true,
|
||||
# "columns": [
|
||||
# {
|
||||
# "title": "Company",
|
||||
# "links": [{ "label": "About", "href": "/about" }]
|
||||
# }
|
||||
# ],
|
||||
# "copyright": "© 2024 {business_name}. All rights reserved.",
|
||||
# "socialLinks": [
|
||||
# { "platform": "facebook", "url": "" },
|
||||
# { "platform": "instagram", "url": "" }
|
||||
# ]
|
||||
# }
|
||||
|
||||
version = models.PositiveIntegerField(default=1)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
```
|
||||
|
||||
#### Page Model Enhancements
|
||||
|
||||
Add SEO and navigation fields to existing Page model:
|
||||
|
||||
```python
|
||||
# Add to existing Page model:
|
||||
meta_title = models.CharField(max_length=255, blank=True)
|
||||
meta_description = models.TextField(blank=True)
|
||||
og_image = models.URLField(blank=True)
|
||||
canonical_url = models.URLField(blank=True)
|
||||
noindex = models.BooleanField(default=False)
|
||||
include_in_nav = models.BooleanField(default=True)
|
||||
hide_chrome = models.BooleanField(default=False) # Landing page mode
|
||||
```
|
||||
|
||||
### Puck Data Schema
|
||||
|
||||
The `puck_data` JSONField stores the Puck editor payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "Section",
|
||||
"props": {
|
||||
"id": "section-abc123",
|
||||
"background": { "type": "color", "value": "#f8fafc" },
|
||||
"padding": "large",
|
||||
"containerWidth": "default",
|
||||
"anchorId": "hero"
|
||||
}
|
||||
}
|
||||
],
|
||||
"root": {},
|
||||
"zones": {
|
||||
"section-abc123:content": [
|
||||
{
|
||||
"type": "Heading",
|
||||
"props": { "text": "Welcome", "level": "h1" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Version Strategy
|
||||
|
||||
- `Page.version` tracks payload schema version
|
||||
- `SiteConfig.version` tracks theme/chrome schema version
|
||||
- Migrations are handled on read (lazy migration)
|
||||
- On save, always write latest version
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Existing (No Changes)
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `GET /api/sites/me/` | GET | Get current site |
|
||||
| `GET /api/sites/me/pages/` | GET | List pages |
|
||||
| `POST /api/sites/me/pages/` | POST | Create page |
|
||||
| `PATCH /api/sites/me/pages/{id}/` | PATCH | Update page |
|
||||
| `DELETE /api/sites/me/pages/{id}/` | DELETE | Delete page |
|
||||
| `GET /api/public/page/` | GET | Get home page (public) |
|
||||
|
||||
### New Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `GET /api/sites/me/config/` | GET | Get site config (theme, chrome) |
|
||||
| `PATCH /api/sites/me/config/` | PATCH | Update site config |
|
||||
| `GET /api/public/page/{slug}/` | GET | Get page by slug (public) |
|
||||
|
||||
## Component Library
|
||||
|
||||
### Categories
|
||||
|
||||
1. **Layout** - Section, Columns, Card, Spacer, Divider
|
||||
2. **Content** - Heading, RichText, Image, Button, IconList, Testimonial, FAQ
|
||||
3. **Booking** - BookingWidget, ServiceCatalog
|
||||
4. **Contact** - ContactForm, BusinessHours, Map
|
||||
|
||||
### Component Specification
|
||||
|
||||
#### Section (Layout)
|
||||
|
||||
The fundamental building block for page sections.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "Section",
|
||||
label: "Section",
|
||||
fields: {
|
||||
background: {
|
||||
type: "custom", // Color picker, image upload, or gradient
|
||||
options: ["none", "color", "image", "gradient"]
|
||||
},
|
||||
overlay: {
|
||||
type: "custom", // Overlay color + opacity
|
||||
},
|
||||
padding: {
|
||||
type: "select",
|
||||
options: ["none", "small", "medium", "large", "xlarge"]
|
||||
},
|
||||
containerWidth: {
|
||||
type: "select",
|
||||
options: ["narrow", "default", "wide", "full"]
|
||||
},
|
||||
anchorId: { type: "text" },
|
||||
hideOnMobile: { type: "checkbox" },
|
||||
hideOnTablet: { type: "checkbox" },
|
||||
hideOnDesktop: { type: "checkbox" }
|
||||
},
|
||||
render: ({ puck }) => (
|
||||
<section>
|
||||
<div className={containerClass}>
|
||||
<DropZone zone="content" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Columns (Layout)
|
||||
|
||||
Flexible column layout with nested drop zones.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "Columns",
|
||||
fields: {
|
||||
columns: {
|
||||
type: "select",
|
||||
options: ["2", "3", "4", "2-1", "1-2"] // ratios
|
||||
},
|
||||
gap: {
|
||||
type: "select",
|
||||
options: ["none", "small", "medium", "large"]
|
||||
},
|
||||
verticalAlign: {
|
||||
type: "select",
|
||||
options: ["top", "center", "bottom", "stretch"]
|
||||
},
|
||||
stackOnMobile: { type: "checkbox", default: true }
|
||||
},
|
||||
render: ({ columns, puck }) => (
|
||||
<div className="grid">
|
||||
{Array.from({ length: columnCount }).map((_, i) => (
|
||||
<DropZone zone={`column-${i}`} key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### BookingWidget (Booking)
|
||||
|
||||
Embedded booking interface - SmoothSchedule's differentiator.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "BookingWidget",
|
||||
fields: {
|
||||
serviceMode: {
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "All Services", value: "all" },
|
||||
{ label: "By Category", value: "category" },
|
||||
{ label: "Specific Services", value: "specific" }
|
||||
]
|
||||
},
|
||||
categoryId: { type: "text" }, // When mode = category
|
||||
serviceIds: { type: "array" }, // When mode = specific
|
||||
showDuration: { type: "checkbox", default: true },
|
||||
showPrice: { type: "checkbox", default: true },
|
||||
showDeposits: { type: "checkbox", default: true },
|
||||
requireLogin: { type: "checkbox", default: false },
|
||||
ctaAfterBooking: { type: "text" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Measures
|
||||
|
||||
### 1. XSS Prevention
|
||||
|
||||
All text content is rendered through React, which auto-escapes HTML by default.
|
||||
|
||||
For rich text (RichText component):
|
||||
- Store content as structured JSON (Slate/Tiptap document format), not raw HTML
|
||||
- Render using a safe renderer that only supports whitelisted elements (p, strong, em, a, ul, ol, li)
|
||||
- Never render raw HTML strings directly into the DOM
|
||||
- All user-provided content goes through React's safe text rendering
|
||||
|
||||
### 2. Embed/Script Injection
|
||||
|
||||
No arbitrary embeds allowed. Map component only supports:
|
||||
- Google Maps embed URLs (maps.google.com/*)
|
||||
- OpenStreetMap iframes
|
||||
|
||||
Implementation:
|
||||
```typescript
|
||||
const ALLOWED_EMBED_DOMAINS = [
|
||||
'www.google.com/maps/embed',
|
||||
'maps.google.com',
|
||||
'www.openstreetmap.org'
|
||||
];
|
||||
|
||||
function isAllowedEmbed(url: string): boolean {
|
||||
return ALLOWED_EMBED_DOMAINS.some(domain =>
|
||||
url.startsWith(`https://${domain}`)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Backend Validation
|
||||
|
||||
```python
|
||||
# In PageSerializer.validate_puck_data()
|
||||
def validate_puck_data(self, value):
|
||||
# 1. Size limit
|
||||
if len(json.dumps(value)) > 5_000_000: # 5MB limit
|
||||
raise ValidationError("Page data too large")
|
||||
|
||||
# 2. Validate structure
|
||||
if not isinstance(value.get('content'), list):
|
||||
raise ValidationError("Invalid puck_data structure")
|
||||
|
||||
# 3. Scan for disallowed content
|
||||
serialized = json.dumps(value).lower()
|
||||
disallowed = ['<script', 'javascript:', 'onerror=', 'onload=']
|
||||
for pattern in disallowed:
|
||||
if pattern in serialized:
|
||||
raise ValidationError("Disallowed content detected")
|
||||
|
||||
return value
|
||||
```
|
||||
|
||||
### 4. Tenant Isolation
|
||||
|
||||
All queries are automatically tenant-scoped:
|
||||
- `get_queryset()` filters by `site__tenant=request.tenant`
|
||||
- `perform_create()` assigns site from tenant context
|
||||
- No cross-tenant data access possible
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Current State Analysis
|
||||
|
||||
The existing implementation already uses Puck with 3 components:
|
||||
- Hero
|
||||
- TextSection
|
||||
- Booking
|
||||
|
||||
No "enum-based component list" migration needed - the system is already Puck-native.
|
||||
|
||||
### Forward Migration
|
||||
|
||||
When adding new component types or changing prop schemas:
|
||||
|
||||
1. **Version field** tracks schema version per page
|
||||
2. **Lazy migration** on read - transform old format to new
|
||||
3. **Save updates version** - always writes latest format
|
||||
|
||||
Example migration:
|
||||
```typescript
|
||||
// v1 → v2: Hero.align was string, now object with breakpoint values
|
||||
function migrateHeroV1toV2(props: any): any {
|
||||
if (typeof props.align === 'string') {
|
||||
return {
|
||||
...props,
|
||||
align: {
|
||||
mobile: 'center',
|
||||
tablet: props.align,
|
||||
desktop: props.align
|
||||
}
|
||||
};
|
||||
}
|
||||
return props;
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Registry
|
||||
|
||||
```typescript
|
||||
const MIGRATIONS: Record<number, (data: PuckData) => PuckData> = {
|
||||
2: migrateV1toV2,
|
||||
3: migrateV2toV3,
|
||||
};
|
||||
|
||||
function migratePuckData(data: PuckData, currentVersion: number): PuckData {
|
||||
let migrated = data;
|
||||
for (let v = currentVersion + 1; v <= LATEST_VERSION; v++) {
|
||||
if (MIGRATIONS[v]) {
|
||||
migrated = MIGRATIONS[v](migrated);
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
```
|
||||
|
||||
## Feature Gating
|
||||
|
||||
### Plan-Based Component Access
|
||||
|
||||
Some components are gated by plan features:
|
||||
- **ContactForm** - requires `can_use_contact_form` feature
|
||||
- **ServiceCatalog** - requires `can_use_service_catalog` feature
|
||||
|
||||
### Implementation
|
||||
|
||||
1. **Config generation** passes feature flags to frontend:
|
||||
```typescript
|
||||
function getComponentConfig(features: Features): Config {
|
||||
const components = { ...baseComponents };
|
||||
|
||||
if (!features.can_use_contact_form) {
|
||||
delete components.ContactForm;
|
||||
}
|
||||
|
||||
return { components };
|
||||
}
|
||||
```
|
||||
|
||||
2. **Rendering** always includes all component renderers:
|
||||
```typescript
|
||||
// Full config for rendering (never gated)
|
||||
const renderConfig = { components: allComponents };
|
||||
|
||||
// Gated config for editing
|
||||
const editorConfig = getComponentConfig(features);
|
||||
```
|
||||
|
||||
This ensures pages with gated components still render correctly, even if the user can't add new instances.
|
||||
|
||||
## Editor UX Enhancements
|
||||
|
||||
### Viewport Toggles
|
||||
|
||||
Desktop (default), Tablet (768px), Mobile (375px)
|
||||
|
||||
### Outline Navigation
|
||||
|
||||
Tree view of page structure with:
|
||||
- Drag to reorder
|
||||
- Click to select
|
||||
- Collapse/expand zones
|
||||
|
||||
### Categorized Component Palette
|
||||
|
||||
- Layout: Section, Columns, Card, Spacer, Divider
|
||||
- Content: Heading, RichText, Image, Button, IconList, Testimonial, FAQ
|
||||
- Booking: BookingWidget, ServiceCatalog
|
||||
- Contact: ContactForm, BusinessHours, Map
|
||||
|
||||
### Page Settings Panel
|
||||
|
||||
Accessible via page icon in header:
|
||||
- Title & slug
|
||||
- Meta title & description
|
||||
- OG image
|
||||
- Canonical URL
|
||||
- Index/noindex toggle
|
||||
- Include in navigation toggle
|
||||
- Hide chrome toggle (landing page mode)
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
smoothschedule/platform/tenant_sites/
|
||||
├── models.py # Site, SiteConfig, Page, Domain
|
||||
├── serializers.py # API serializers with validation
|
||||
├── views.py # ViewSets and API views
|
||||
├── validators.py # Puck data validation helpers
|
||||
├── migrations/
|
||||
│ └── 0002_siteconfig_page_seo_fields.py
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_models.py
|
||||
├── test_serializers.py
|
||||
├── test_views.py
|
||||
└── test_tenant_isolation.py
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── puck/
|
||||
│ ├── config.ts # Main Puck config export
|
||||
│ ├── types.ts # Component prop types
|
||||
│ ├── migrations.ts # Data migration functions
|
||||
│ ├── components/
|
||||
│ │ ├── layout/
|
||||
│ │ │ ├── Section.tsx
|
||||
│ │ │ ├── Columns.tsx
|
||||
│ │ │ ├── Card.tsx
|
||||
│ │ │ ├── Spacer.tsx
|
||||
│ │ │ └── Divider.tsx
|
||||
│ │ ├── content/
|
||||
│ │ │ ├── Heading.tsx
|
||||
│ │ │ ├── RichText.tsx
|
||||
│ │ │ ├── Image.tsx
|
||||
│ │ │ ├── Button.tsx
|
||||
│ │ │ ├── IconList.tsx
|
||||
│ │ │ ├── Testimonial.tsx
|
||||
│ │ │ └── FAQ.tsx
|
||||
│ │ ├── booking/
|
||||
│ │ │ ├── BookingWidget.tsx
|
||||
│ │ │ └── ServiceCatalog.tsx
|
||||
│ │ └── contact/
|
||||
│ │ ├── ContactForm.tsx
|
||||
│ │ ├── BusinessHours.tsx
|
||||
│ │ └── Map.tsx
|
||||
│ └── fields/
|
||||
│ ├── ColorPicker.tsx
|
||||
│ ├── BackgroundPicker.tsx
|
||||
│ └── RichTextEditor.tsx
|
||||
├── pages/
|
||||
│ ├── PageEditor.tsx # Enhanced editor
|
||||
│ └── PublicPage.tsx # Public renderer
|
||||
├── hooks/
|
||||
│ └── useSites.ts # Site/Page/Config hooks
|
||||
└── __tests__/
|
||||
└── puck/
|
||||
├── migrations.test.ts
|
||||
├── components.test.tsx
|
||||
└── config.test.ts
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Tests First** (TDD)
|
||||
- Backend: tenant isolation, CRUD, validation
|
||||
- Frontend: migration, rendering, feature gating
|
||||
|
||||
2. **Data Model**
|
||||
- Add SiteConfig model
|
||||
- Add Page SEO fields
|
||||
- Create migrations
|
||||
|
||||
3. **API**
|
||||
- SiteConfig endpoints
|
||||
- Enhanced PageSerializer validation
|
||||
|
||||
4. **Components**
|
||||
- Layout primitives (Section, Columns)
|
||||
- Content blocks (Heading, RichText, Image)
|
||||
- Booking blocks (enhanced BookingWidget)
|
||||
- Contact blocks (ContactForm, BusinessHours)
|
||||
|
||||
5. **Editor**
|
||||
- Viewport toggles
|
||||
- Categorized palette
|
||||
- Page settings panel
|
||||
|
||||
6. **Public Rendering**
|
||||
- Apply theme tokens
|
||||
- Render header/footer chrome
|
||||
@@ -13,7 +13,10 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
|
||||
├── frontend/ # This React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── api/client.ts # Axios API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── components/ # Feature components
|
||||
│ │ │ └── ui/ # Reusable UI components (see below)
|
||||
│ │ ├── constants/ # Shared constants
|
||||
│ │ │ └── schedulePresets.ts # Schedule/cron presets
|
||||
│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.)
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── types.ts # TypeScript interfaces
|
||||
@@ -31,6 +34,125 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
|
||||
└── users/ # User management
|
||||
```
|
||||
|
||||
## Reusable UI Components
|
||||
|
||||
All reusable UI components are in `src/components/ui/`. Import from the barrel file:
|
||||
|
||||
```typescript
|
||||
import { Modal, FormInput, Button, Alert } from '../components/ui';
|
||||
```
|
||||
|
||||
### Available Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| **Modal** | Reusable modal dialog with header, body, footer |
|
||||
| **ModalFooter** | Standardized modal footer with buttons |
|
||||
| **FormInput** | Text input with label, error, hint support |
|
||||
| **FormSelect** | Select dropdown with label, error support |
|
||||
| **FormTextarea** | Textarea with label, error support |
|
||||
| **FormCurrencyInput** | ATM-style currency input (cents) |
|
||||
| **CurrencyInput** | Raw currency input component |
|
||||
| **Button** | Button with variants, loading state, icons |
|
||||
| **SubmitButton** | Pre-configured submit button |
|
||||
| **Alert** | Alert banner (error, success, warning, info) |
|
||||
| **ErrorMessage** | Error alert shorthand |
|
||||
| **SuccessMessage** | Success alert shorthand |
|
||||
| **TabGroup** | Tab navigation (default, pills, underline) |
|
||||
| **StepIndicator** | Multi-step wizard indicator |
|
||||
| **LoadingSpinner** | Loading spinner with variants |
|
||||
| **PageLoading** | Full page loading state |
|
||||
| **Card** | Card container with header/body/footer |
|
||||
| **EmptyState** | Empty state placeholder |
|
||||
| **Badge** | Status badges |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// Modal with form
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Edit Resource" size="lg">
|
||||
<FormInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={errors.name}
|
||||
required
|
||||
/>
|
||||
<FormSelect
|
||||
label="Type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
options={[
|
||||
{ value: 'STAFF', label: 'Staff' },
|
||||
{ value: 'ROOM', label: 'Room' },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
// Alert messages
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{success && <SuccessMessage message="Saved successfully!" />}
|
||||
|
||||
// Tabs
|
||||
<TabGroup
|
||||
tabs={[
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'schedule', label: 'Schedule' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
```
|
||||
|
||||
## Utility Hooks
|
||||
|
||||
### useCrudMutation
|
||||
|
||||
Factory hook for CRUD mutations with React Query:
|
||||
|
||||
```typescript
|
||||
import { useCrudMutation, createCrudHooks } from '../hooks/useCrudMutation';
|
||||
|
||||
// Simple usage
|
||||
const createResource = useCrudMutation<Resource, CreateResourceData>({
|
||||
endpoint: '/resources',
|
||||
method: 'POST',
|
||||
invalidateKeys: [['resources']],
|
||||
});
|
||||
|
||||
// Create all CRUD hooks at once
|
||||
const { useCreate, useUpdate, useDelete } = createCrudHooks<Resource>('/resources', 'resources');
|
||||
```
|
||||
|
||||
### useFormValidation
|
||||
|
||||
Schema-based form validation:
|
||||
|
||||
```typescript
|
||||
import { useFormValidation, required, email, minLength } from '../hooks/useFormValidation';
|
||||
|
||||
const schema = {
|
||||
email: [required('Email is required'), email('Invalid email')],
|
||||
password: [required(), minLength(8, 'Min 8 characters')],
|
||||
};
|
||||
|
||||
const { errors, validateForm, isValid } = useFormValidation(schema);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (validateForm(formData)) {
|
||||
// Submit
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Constants
|
||||
|
||||
### Schedule Presets
|
||||
|
||||
```typescript
|
||||
import { SCHEDULE_PRESETS, TRIGGER_OPTIONS, OFFSET_PRESETS } from '../constants/schedulePresets';
|
||||
```
|
||||
|
||||
## Local Development Domain Setup
|
||||
|
||||
### Why lvh.me instead of localhost?
|
||||
|
||||
@@ -63,6 +63,19 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections to Django (Daphne/ASGI)
|
||||
location /ws/ {
|
||||
proxy_pass http://django:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
|
||||
location /static/ {
|
||||
proxy_pass http://django:5000;
|
||||
|
||||
216
frontend/package-lock.json
generated
216
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@measured/puck": "^0.20.2",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -578,6 +580,17 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/abstract": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.1.21.tgz",
|
||||
"integrity": "sha512-6sJut6/D21xPIK8EFMu+JJeF+fBCOmQKN1BRpeUYFi5m9P1CJpTYbBwfI107h7PHObI6a5bsckiKkRpF2orHpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/geometry": "^0.1.21",
|
||||
"@dnd-kit/state": "^0.1.21",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
@@ -590,6 +603,17 @@
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/collision": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.1.21.tgz",
|
||||
"integrity": "sha512-9AJ4NbuwGDexxMCZXZyKdNQhbAe93p6C6IezQaDaWmdCqZHMHmC3+ul7pGefBQfOooSarGwIf8Bn182o9SMa1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.1.21",
|
||||
"@dnd-kit/geometry": "^0.1.21",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
@@ -605,6 +629,65 @@
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/dom": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.1.21.tgz",
|
||||
"integrity": "sha512-6UDc1y2Y3oLQKArGlgCrZxz5pdEjRSiQujXOn5JdbuWvKqTdUR5RTYDeicr+y2sVm3liXjTqs3WlUoV+eqhqUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.1.21",
|
||||
"@dnd-kit/collision": "^0.1.21",
|
||||
"@dnd-kit/geometry": "^0.1.21",
|
||||
"@dnd-kit/state": "^0.1.21",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/geometry": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.1.21.tgz",
|
||||
"integrity": "sha512-Tir97wNJbopN2HgkD7AjAcoB3vvrVuUHvwdPALmNDUH0fWR637c4MKQ66YjjZAbUEAR8KL6mlDiHH4MzTLd7CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/state": "^0.1.21",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/helpers": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/helpers/-/helpers-0.1.18.tgz",
|
||||
"integrity": "sha512-k4hVXIb8ysPt+J0KOxbBTc6rG0JSlsrNevI/fCHLbyXvEyj1imxl7yOaAQX13cAZnte88db6JvbgsSWlVjtxbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.1.18",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/react": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.1.18.tgz",
|
||||
"integrity": "sha512-OCeCO9WbKnN4rVlEOEe9QWxSIFzP0m/fBFmVYfu2pDSb4pemRkfrvCsI/FH3jonuESYS8qYnN9vc8Vp3EiCWCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.1.18",
|
||||
"@dnd-kit/dom": "^0.1.18",
|
||||
"@dnd-kit/state": "^0.1.18",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/state": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.1.21.tgz",
|
||||
"integrity": "sha512-pdhntEPvn/QttcF295bOJpWiLsRqA/Iczh1ODOJUxGiR+E4GkYVz9VapNNm9gDq6ST0tr/e1Q2xBztUHlJqQgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.10.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
@@ -1319,6 +1402,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@measured/puck": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@measured/puck/-/puck-0.20.2.tgz",
|
||||
"integrity": "sha512-/GuzlsGs1T2S3lY9so4GyHpDBlWnC1h/4rkYuelrLNHvacnXBZyn50hvgRhWAqlLn/xOuJvJeuY740Zemxdt3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/helpers": "0.1.18",
|
||||
"@dnd-kit/react": "0.1.18",
|
||||
"deep-diff": "^1.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"flat": "^5.0.2",
|
||||
"object-hash": "^3.0.0",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
"use-debounce": "^9.0.4",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
@@ -1335,6 +1439,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@preact/signals-core": {
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz",
|
||||
"integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-google-maps/api": {
|
||||
"version": "2.20.7",
|
||||
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz",
|
||||
@@ -2071,7 +2185,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -2160,8 +2273,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -2598,7 +2710,6 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -3260,6 +3371,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-diff": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
|
||||
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3300,8 +3417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -3774,6 +3890,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flat": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
|
||||
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"flat": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
@@ -4972,7 +5097,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -5179,6 +5303,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -5415,7 +5548,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -5431,7 +5563,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -5444,8 +5575,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
@@ -5568,6 +5698,16 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hotkeys-hook": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.2.tgz",
|
||||
"integrity": "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.1",
|
||||
"react-dom": ">=16.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
|
||||
@@ -6218,6 +6358,18 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-debounce": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz",
|
||||
"integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
@@ -6227,6 +6379,19 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
@@ -6599,6 +6764,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@measured/puck": "^0.20.2",
|
||||
"@react-google-maps/api": "^2.20.7",
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@@ -63,6 +63,7 @@ const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/Platfor
|
||||
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
|
||||
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
||||
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
||||
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
||||
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||
@@ -110,6 +111,10 @@ const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Im
|
||||
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
|
||||
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
|
||||
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
||||
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
|
||||
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
|
||||
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
||||
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
|
||||
|
||||
// Settings pages
|
||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||
@@ -124,6 +129,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
|
||||
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
||||
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
@@ -346,7 +352,8 @@ const AppContent: React.FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
|
||||
<Route path="/" element={<PublicPage />} />
|
||||
<Route path="/book" element={<BookingFlow />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
@@ -490,7 +497,10 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
<>
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
<Route path="/platform/billing" element={<BillingManagement />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
@@ -624,7 +634,7 @@ const AppContent: React.FC = () => {
|
||||
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 allowedWhenExpired = ['/dashboard/trial-expired', '/dashboard/upgrade', '/dashboard/settings', '/dashboard/profile'];
|
||||
const currentPath = window.location.pathname;
|
||||
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
||||
|
||||
@@ -633,15 +643,15 @@ const AppContent: React.FC = () => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
||||
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
||||
{/* Trial-expired users can access billing settings to upgrade */}
|
||||
<Route
|
||||
path="/settings/*"
|
||||
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
|
||||
path="/dashboard/settings/*"
|
||||
element={hasAccess(['owner']) ? <Navigate to="/dashboard/upgrade" /> : <Navigate to="/dashboard/trial-expired" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/trial-expired" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -662,30 +672,33 @@ const AppContent: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Redirect root to dashboard */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Trial and Upgrade Routes */}
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
||||
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
path="/dashboard"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||
/>
|
||||
{/* Staff Schedule - vertical timeline view */}
|
||||
<Route
|
||||
path="/my-schedule"
|
||||
path="/dashboard/my-schedule"
|
||||
element={
|
||||
hasAccess(['staff']) ? (
|
||||
<StaffSchedule user={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/tickets" element={<Tickets />} />
|
||||
<Route path="/dashboard/scheduler" element={<Scheduler />} />
|
||||
<Route path="/dashboard/tickets" element={<Tickets />} />
|
||||
<Route
|
||||
path="/help"
|
||||
path="/dashboard/help"
|
||||
element={
|
||||
user.role === 'staff' ? (
|
||||
<StaffHelp user={user} />
|
||||
@@ -694,189 +707,210 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
|
||||
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
|
||||
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
|
||||
{/* New help pages */}
|
||||
<Route path="/help/dashboard" element={<HelpDashboard />} />
|
||||
<Route path="/help/scheduler" element={<HelpScheduler />} />
|
||||
<Route path="/help/tasks" element={<HelpTasks />} />
|
||||
<Route path="/help/customers" element={<HelpCustomers />} />
|
||||
<Route path="/help/services" element={<HelpServices />} />
|
||||
<Route path="/help/resources" element={<HelpResources />} />
|
||||
<Route path="/help/staff" element={<HelpStaff />} />
|
||||
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
|
||||
<Route path="/help/messages" element={<HelpMessages />} />
|
||||
<Route path="/help/payments" element={<HelpPayments />} />
|
||||
<Route path="/help/contracts" element={<HelpContracts />} />
|
||||
<Route path="/help/plugins" element={<HelpPlugins />} />
|
||||
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
|
||||
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
||||
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
|
||||
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
|
||||
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
|
||||
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
|
||||
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
|
||||
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
|
||||
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
|
||||
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
|
||||
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
|
||||
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
|
||||
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
|
||||
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
|
||||
<Route path="/dashboard/help/services" element={<HelpServices />} />
|
||||
<Route path="/dashboard/help/resources" element={<HelpResources />} />
|
||||
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
|
||||
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
|
||||
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
|
||||
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
|
||||
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
||||
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
|
||||
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
|
||||
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
||||
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
|
||||
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
|
||||
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
|
||||
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
|
||||
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
|
||||
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
|
||||
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
|
||||
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
|
||||
<Route
|
||||
path="/plugins/marketplace"
|
||||
path="/dashboard/plugins/marketplace"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<PluginMarketplace />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/my-plugins"
|
||||
path="/dashboard/plugins/my-plugins"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<MyPlugins />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/create"
|
||||
path="/dashboard/plugins/create"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<CreatePlugin />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks"
|
||||
path="/dashboard/tasks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Tasks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/email-templates"
|
||||
path="/dashboard/email-templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<EmailTemplates />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/support" element={<PlatformSupport />} />
|
||||
<Route path="/dashboard/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
path="/dashboard/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services"
|
||||
path="/dashboard/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resources"
|
||||
path="/dashboard/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff"
|
||||
path="/dashboard/staff"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/time-blocks"
|
||||
path="/dashboard/time-blocks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<TimeBlocks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-availability"
|
||||
path="/dashboard/locations"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Locations />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/my-availability"
|
||||
element={
|
||||
hasAccess(['staff', 'resource']) ? (
|
||||
<MyAvailability user={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts"
|
||||
path="/dashboard/contracts"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
<Contracts />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/templates"
|
||||
path="/dashboard/contracts/templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
<ContractTemplates />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
path="/dashboard/payments"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/messages"
|
||||
path="/dashboard/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
||||
<Messages />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/site-editor"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<PageEditor />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Settings Routes with Nested Layout */}
|
||||
{hasAccess(['owner']) ? (
|
||||
<Route path="/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/settings/general" replace />} />
|
||||
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
|
||||
<Route path="general" element={<GeneralSettings />} />
|
||||
<Route path="branding" element={<BrandingSettings />} />
|
||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||
<Route path="booking" element={<BookingSettings />} />
|
||||
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||
<Route path="email-templates" element={<EmailTemplates />} />
|
||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
@@ -887,11 +921,11 @@ const AppContent: React.FC = () => {
|
||||
<Route path="quota" element={<QuotaSettings />} />
|
||||
</Route>
|
||||
) : (
|
||||
<Route path="/settings/*" element={<Navigate to="/" />} />
|
||||
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
|
||||
)}
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
||||
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
214
frontend/src/api/__tests__/billing.test.ts
Normal file
214
frontend/src/api/__tests__/billing.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Tests for Billing API client functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import apiClient from '../client';
|
||||
import {
|
||||
getEntitlements,
|
||||
getCurrentSubscription,
|
||||
getPlans,
|
||||
getAddOns,
|
||||
getInvoices,
|
||||
getInvoice,
|
||||
Entitlements,
|
||||
Subscription,
|
||||
PlanVersion,
|
||||
AddOnProduct,
|
||||
Invoice,
|
||||
} from '../billing';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Billing API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEntitlements', () => {
|
||||
it('fetches entitlements from /api/me/entitlements/', async () => {
|
||||
const mockEntitlements: Entitlements = {
|
||||
can_use_sms_reminders: true,
|
||||
can_use_mobile_app: false,
|
||||
max_users: 10,
|
||||
max_resources: 25,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
|
||||
|
||||
const result = await getEntitlements();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
|
||||
expect(result).toEqual(mockEntitlements);
|
||||
});
|
||||
|
||||
it('returns empty object on error', async () => {
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await getEntitlements();
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentSubscription', () => {
|
||||
it('fetches subscription from /api/me/subscription/', async () => {
|
||||
const mockSubscription: Subscription = {
|
||||
id: 1,
|
||||
status: 'active',
|
||||
plan_version: {
|
||||
id: 10,
|
||||
name: 'Pro Plan v1',
|
||||
is_legacy: false,
|
||||
plan: { code: 'pro', name: 'Pro' },
|
||||
price_monthly_cents: 7900,
|
||||
price_yearly_cents: 79000,
|
||||
},
|
||||
current_period_start: '2024-01-01T00:00:00Z',
|
||||
current_period_end: '2024-02-01T00:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
|
||||
|
||||
const result = await getCurrentSubscription();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
|
||||
expect(result).toEqual(mockSubscription);
|
||||
});
|
||||
|
||||
it('returns null when no subscription (404)', async () => {
|
||||
const error = { response: { status: 404 } };
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
|
||||
|
||||
const result = await getCurrentSubscription();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlans', () => {
|
||||
it('fetches public plans from /api/billing/plans/', async () => {
|
||||
const mockPlans: PlanVersion[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Free Plan',
|
||||
is_legacy: false,
|
||||
is_public: true,
|
||||
plan: { code: 'free', name: 'Free' },
|
||||
price_monthly_cents: 0,
|
||||
price_yearly_cents: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Pro Plan',
|
||||
is_legacy: false,
|
||||
is_public: true,
|
||||
plan: { code: 'pro', name: 'Pro' },
|
||||
price_monthly_cents: 7900,
|
||||
price_yearly_cents: 79000,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
|
||||
|
||||
const result = await getPlans();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
|
||||
expect(result).toEqual(mockPlans);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAddOns', () => {
|
||||
it('fetches active add-ons from /api/billing/addons/', async () => {
|
||||
const mockAddOns: AddOnProduct[] = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'sms_pack',
|
||||
name: 'SMS Pack',
|
||||
price_monthly_cents: 500,
|
||||
price_one_time_cents: 0,
|
||||
is_stackable: false,
|
||||
is_active: true,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
|
||||
|
||||
const result = await getAddOns();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
|
||||
expect(result).toEqual(mockAddOns);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvoices', () => {
|
||||
it('fetches invoices from /api/billing/invoices/', async () => {
|
||||
const mockInvoices: Invoice[] = [
|
||||
{
|
||||
id: 1,
|
||||
status: 'paid',
|
||||
period_start: '2024-01-01T00:00:00Z',
|
||||
period_end: '2024-02-01T00:00:00Z',
|
||||
subtotal_amount: 7900,
|
||||
total_amount: 7900,
|
||||
plan_name_at_billing: 'Pro Plan',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
|
||||
|
||||
const result = await getInvoices();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
|
||||
expect(result).toEqual(mockInvoices);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvoice', () => {
|
||||
it('fetches a single invoice by ID', async () => {
|
||||
const mockInvoice: Invoice = {
|
||||
id: 1,
|
||||
status: 'paid',
|
||||
period_start: '2024-01-01T00:00:00Z',
|
||||
period_end: '2024-02-01T00:00:00Z',
|
||||
subtotal_amount: 7900,
|
||||
total_amount: 7900,
|
||||
plan_name_at_billing: 'Pro Plan',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
lines: [
|
||||
{
|
||||
id: 1,
|
||||
line_type: 'plan',
|
||||
description: 'Pro Plan',
|
||||
quantity: 1,
|
||||
unit_amount: 7900,
|
||||
total_amount: 7900,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
|
||||
|
||||
const result = await getInvoice(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
|
||||
expect(result).toEqual(mockInvoice);
|
||||
});
|
||||
|
||||
it('returns null when invoice not found (404)', async () => {
|
||||
const error = { response: { status: 404 } };
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
|
||||
|
||||
const result = await getInvoice(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
188
frontend/src/api/billing.ts
Normal file
188
frontend/src/api/billing.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Billing API
|
||||
*
|
||||
* API client functions for the billing/subscription system.
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Entitlements - a map of feature codes to their values.
|
||||
* Boolean features indicate permission (true/false).
|
||||
* Integer features indicate limits.
|
||||
*/
|
||||
export interface Entitlements {
|
||||
[key: string]: boolean | number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan information (nested in PlanVersion)
|
||||
*/
|
||||
export interface Plan {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan version with pricing and features
|
||||
*/
|
||||
export interface PlanVersion {
|
||||
id: number;
|
||||
name: string;
|
||||
is_legacy: boolean;
|
||||
is_public?: boolean;
|
||||
plan: Plan;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents: number;
|
||||
features?: PlanFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature attached to a plan version
|
||||
*/
|
||||
export interface PlanFeature {
|
||||
feature_code: string;
|
||||
feature_name: string;
|
||||
feature_type: 'boolean' | 'integer';
|
||||
bool_value?: boolean;
|
||||
int_value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current subscription
|
||||
*/
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
status: 'active' | 'canceled' | 'past_due' | 'trialing';
|
||||
plan_version: PlanVersion;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
canceled_at?: string;
|
||||
stripe_subscription_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-on product
|
||||
*/
|
||||
export interface AddOnProduct {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price_monthly_cents: number;
|
||||
price_one_time_cents: number;
|
||||
stripe_product_id?: string;
|
||||
stripe_price_id?: string;
|
||||
is_stackable: boolean;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice line item
|
||||
*/
|
||||
export interface InvoiceLine {
|
||||
id: number;
|
||||
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit_amount: number;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice
|
||||
*/
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
subtotal_amount: number;
|
||||
total_amount: number;
|
||||
plan_name_at_billing: string;
|
||||
plan_code_at_billing?: string;
|
||||
created_at: string;
|
||||
paid_at?: string;
|
||||
lines?: InvoiceLine[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get effective entitlements for the current business.
|
||||
* Returns a map of feature codes to their values.
|
||||
*/
|
||||
export const getEntitlements = async (): Promise<Entitlements> => {
|
||||
try {
|
||||
const response = await apiClient.get<Entitlements>('/me/entitlements/');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch entitlements:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current subscription for the business.
|
||||
* Returns null if no subscription exists.
|
||||
*/
|
||||
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
|
||||
try {
|
||||
const response = await apiClient.get<Subscription>('/me/subscription/');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error('Failed to fetch subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available plans (public, non-legacy plans).
|
||||
*/
|
||||
export const getPlans = async (): Promise<PlanVersion[]> => {
|
||||
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available add-on products.
|
||||
*/
|
||||
export const getAddOns = async (): Promise<AddOnProduct[]> => {
|
||||
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get invoices for the current business.
|
||||
*/
|
||||
export const getInvoices = async (): Promise<Invoice[]> => {
|
||||
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single invoice by ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
|
||||
try {
|
||||
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error('Failed to fetch invoice:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -443,7 +443,6 @@ export interface SubscriptionPlan {
|
||||
name: string;
|
||||
description: string;
|
||||
plan_type: 'base' | 'addon';
|
||||
business_tier: string;
|
||||
price_monthly: number | null;
|
||||
price_yearly: number | null;
|
||||
features: string[];
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PlatformBusiness {
|
||||
owner: PlatformBusinessOwner | null;
|
||||
max_users: number;
|
||||
max_resources: number;
|
||||
max_pages: number;
|
||||
contact_email?: string;
|
||||
phone?: string;
|
||||
// Platform permissions
|
||||
@@ -33,6 +34,25 @@ export interface PlatformBusiness {
|
||||
can_use_custom_domain: boolean;
|
||||
can_white_label: boolean;
|
||||
can_api_access: boolean;
|
||||
// Feature permissions (optional - returned by API but may not always be present in tests)
|
||||
can_add_video_conferencing?: boolean;
|
||||
can_connect_to_api?: boolean;
|
||||
can_book_repeated_events?: boolean;
|
||||
can_require_2fa?: boolean;
|
||||
can_download_logs?: boolean;
|
||||
can_delete_data?: boolean;
|
||||
can_use_sms_reminders?: boolean;
|
||||
can_use_masked_phone_numbers?: boolean;
|
||||
can_use_pos?: boolean;
|
||||
can_use_mobile_app?: boolean;
|
||||
can_export_data?: boolean;
|
||||
can_use_plugins?: boolean;
|
||||
can_use_tasks?: boolean;
|
||||
can_create_plugins?: boolean;
|
||||
can_use_webhooks?: boolean;
|
||||
can_use_calendar_sync?: boolean;
|
||||
can_use_contracts?: boolean;
|
||||
can_customize_booking_page?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessUpdate {
|
||||
@@ -41,11 +61,39 @@ export interface PlatformBusinessUpdate {
|
||||
subscription_tier?: string;
|
||||
max_users?: number;
|
||||
max_resources?: number;
|
||||
max_pages?: number;
|
||||
// Platform permissions
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
can_accept_payments?: boolean;
|
||||
can_use_custom_domain?: boolean;
|
||||
can_white_label?: boolean;
|
||||
can_api_access?: boolean;
|
||||
// Feature permissions
|
||||
can_add_video_conferencing?: boolean;
|
||||
can_connect_to_api?: boolean;
|
||||
can_book_repeated_events?: boolean;
|
||||
can_require_2fa?: boolean;
|
||||
can_download_logs?: boolean;
|
||||
can_delete_data?: boolean;
|
||||
can_use_sms_reminders?: boolean;
|
||||
can_use_masked_phone_numbers?: boolean;
|
||||
can_use_pos?: boolean;
|
||||
can_use_mobile_app?: boolean;
|
||||
can_export_data?: boolean;
|
||||
can_use_plugins?: boolean;
|
||||
can_use_tasks?: boolean;
|
||||
can_create_plugins?: boolean;
|
||||
can_use_webhooks?: boolean;
|
||||
can_use_calendar_sync?: boolean;
|
||||
can_use_contracts?: boolean;
|
||||
can_customize_booking_page?: boolean;
|
||||
can_process_refunds?: boolean;
|
||||
can_create_packages?: boolean;
|
||||
can_use_email_templates?: boolean;
|
||||
advanced_reporting?: boolean;
|
||||
priority_support?: boolean;
|
||||
dedicated_support?: boolean;
|
||||
sso_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessCreate {
|
||||
@@ -55,6 +103,7 @@ export interface PlatformBusinessCreate {
|
||||
is_active?: boolean;
|
||||
max_users?: number;
|
||||
max_resources?: number;
|
||||
max_pages?: number;
|
||||
contact_email?: string;
|
||||
phone?: string;
|
||||
can_manage_oauth_credentials?: boolean;
|
||||
@@ -103,6 +152,27 @@ export const updateBusiness = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change a business's subscription plan (platform admin only)
|
||||
*/
|
||||
export interface ChangePlanResponse {
|
||||
detail: string;
|
||||
plan_code: string;
|
||||
plan_name: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const changeBusinessPlan = async (
|
||||
businessId: number,
|
||||
planCode: string
|
||||
): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post<ChangePlanResponse>(
|
||||
`/platform/businesses/${businessId}/change_plan/`,
|
||||
{ plan_code: planCode }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new business (platform admin only)
|
||||
*/
|
||||
@@ -280,3 +350,46 @@ export const acceptInvitation = async (
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Custom Tier
|
||||
// ============================================================================
|
||||
|
||||
import { TenantCustomTier } from '../types';
|
||||
|
||||
/**
|
||||
* Get a business's custom tier (if it exists)
|
||||
*/
|
||||
export const getCustomTier = async (businessId: number): Promise<TenantCustomTier | null> => {
|
||||
try {
|
||||
const response = await apiClient.get<TenantCustomTier>(`/platform/businesses/${businessId}/custom_tier/`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update or create a custom tier for a business
|
||||
*/
|
||||
export const updateCustomTier = async (
|
||||
businessId: number,
|
||||
features: Record<string, boolean | number>,
|
||||
notes?: string
|
||||
): Promise<TenantCustomTier> => {
|
||||
const response = await apiClient.put<TenantCustomTier>(
|
||||
`/platform/businesses/${businessId}/custom_tier/`,
|
||||
{ features, notes }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a business's custom tier
|
||||
*/
|
||||
export const deleteCustomTier = async (businessId: number): Promise<void> => {
|
||||
await apiClient.delete(`/platform/businesses/${businessId}/custom_tier/`);
|
||||
};
|
||||
|
||||
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal file
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* AddOnEditorModal Component
|
||||
*
|
||||
* Modal for creating or editing add-on products with feature selection.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, FormInput, Alert } from '../../components/ui';
|
||||
import { FeaturePicker } from './FeaturePicker';
|
||||
import {
|
||||
useFeatures,
|
||||
useCreateAddOnProduct,
|
||||
useUpdateAddOnProduct,
|
||||
type AddOnProduct,
|
||||
type AddOnFeatureWrite,
|
||||
} from '../../hooks/useBillingAdmin';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface AddOnEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addon?: AddOnProduct | null;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price_monthly_cents: number;
|
||||
price_one_time_cents: number;
|
||||
stripe_product_id: string;
|
||||
stripe_price_id: string;
|
||||
is_stackable: boolean;
|
||||
is_active: boolean;
|
||||
selectedFeatures: AddOnFeatureWrite[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const AddOnEditorModal: React.FC<AddOnEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
addon,
|
||||
}) => {
|
||||
const isEditMode = !!addon;
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: 0,
|
||||
price_one_time_cents: 0,
|
||||
stripe_product_id: '',
|
||||
stripe_price_id: '',
|
||||
is_stackable: false,
|
||||
is_active: true,
|
||||
selectedFeatures: [],
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
// Fetch features
|
||||
const { data: features, isLoading: featuresLoading } = useFeatures();
|
||||
|
||||
const createMutation = useCreateAddOnProduct();
|
||||
const updateMutation = useUpdateAddOnProduct();
|
||||
|
||||
// Initialize form when addon changes
|
||||
useEffect(() => {
|
||||
if (addon) {
|
||||
setFormData({
|
||||
code: addon.code,
|
||||
name: addon.name,
|
||||
description: addon.description || '',
|
||||
price_monthly_cents: addon.price_monthly_cents,
|
||||
price_one_time_cents: addon.price_one_time_cents,
|
||||
stripe_product_id: addon.stripe_product_id || '',
|
||||
stripe_price_id: addon.stripe_price_id || '',
|
||||
is_stackable: addon.is_stackable,
|
||||
is_active: addon.is_active,
|
||||
selectedFeatures:
|
||||
addon.features?.map((af) => ({
|
||||
feature_code: af.feature.code,
|
||||
bool_value: af.bool_value,
|
||||
int_value: af.int_value,
|
||||
})) || [],
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: 0,
|
||||
price_one_time_cents: 0,
|
||||
stripe_product_id: '',
|
||||
stripe_price_id: '',
|
||||
is_stackable: false,
|
||||
is_active: true,
|
||||
selectedFeatures: [],
|
||||
});
|
||||
}
|
||||
setErrors({});
|
||||
}, [addon, isOpen]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof FormData, string>> = {};
|
||||
|
||||
if (!formData.code.trim()) {
|
||||
newErrors.code = 'Code is required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(formData.code)) {
|
||||
newErrors.code = 'Code must be lowercase letters, numbers, and underscores only';
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (formData.price_monthly_cents < 0) {
|
||||
newErrors.price_monthly_cents = 'Price cannot be negative';
|
||||
}
|
||||
|
||||
if (formData.price_one_time_cents < 0) {
|
||||
newErrors.price_one_time_cents = 'Price cannot be negative';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const payload = {
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
price_monthly_cents: formData.price_monthly_cents,
|
||||
price_one_time_cents: formData.price_one_time_cents,
|
||||
stripe_product_id: formData.stripe_product_id,
|
||||
stripe_price_id: formData.stripe_price_id,
|
||||
is_stackable: formData.is_stackable,
|
||||
is_active: formData.is_active,
|
||||
features: formData.selectedFeatures,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEditMode && addon) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: addon.id,
|
||||
...payload,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save add-on:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof FormData, value: string | number | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditMode ? `Edit ${addon?.name}` : 'Create Add-On'}
|
||||
size="4xl"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Basic Information</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormInput
|
||||
label="Code"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleChange('code', e.target.value)}
|
||||
error={errors.code}
|
||||
placeholder="sms_credits_pack"
|
||||
disabled={isEditMode}
|
||||
hint={isEditMode ? 'Code cannot be changed' : 'Unique identifier (lowercase, underscores)'}
|
||||
/>
|
||||
<FormInput
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder="SMS Credits Pack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
placeholder="Description of the add-on..."
|
||||
rows={2}
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<label htmlFor="is_active" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Active (available for purchase)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_stackable"
|
||||
checked={formData.is_stackable}
|
||||
onChange={(e) => handleChange('is_stackable', e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<label htmlFor="is_stackable" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Stackable (can purchase multiple, values compound)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Pricing</h4>
|
||||
|
||||
<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">
|
||||
Monthly Price
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={(formData.price_monthly_cents / 100).toFixed(2)}
|
||||
onChange={(e) =>
|
||||
handleChange('price_monthly_cents', Math.round(parseFloat(e.target.value || '0') * 100))
|
||||
}
|
||||
className="w-full pl-7 pr-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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errors.price_monthly_cents && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.price_monthly_cents}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
One-Time Price
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={(formData.price_one_time_cents / 100).toFixed(2)}
|
||||
onChange={(e) =>
|
||||
handleChange('price_one_time_cents', Math.round(parseFloat(e.target.value || '0') * 100))
|
||||
}
|
||||
className="w-full pl-7 pr-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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{errors.price_one_time_cents && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.price_one_time_cents}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
For one-time purchases (credits, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Features Granted by This Add-On
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the features that subscribers will receive when they purchase this add-on.
|
||||
</p>
|
||||
|
||||
{featuresLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
||||
</div>
|
||||
) : (
|
||||
<FeaturePicker
|
||||
features={features || []}
|
||||
selectedFeatures={formData.selectedFeatures}
|
||||
onChange={(selected) =>
|
||||
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stripe Integration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Stripe Integration</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormInput
|
||||
label="Stripe Product ID"
|
||||
value={formData.stripe_product_id}
|
||||
onChange={(e) => handleChange('stripe_product_id', e.target.value)}
|
||||
placeholder="prod_..."
|
||||
/>
|
||||
<FormInput
|
||||
label="Stripe Price ID"
|
||||
value={formData.stripe_price_id}
|
||||
onChange={(e) => handleChange('stripe_price_id', e.target.value)}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!formData.stripe_product_id && (
|
||||
<Alert
|
||||
variant="info"
|
||||
message="Configure Stripe IDs to enable purchasing. Create the product in Stripe Dashboard first."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
className="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 flex items-center gap-2"
|
||||
>
|
||||
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isEditMode ? 'Save Changes' : 'Create Add-On'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal file
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* CatalogListPanel Component
|
||||
*
|
||||
* Left sidebar panel displaying a searchable, filterable list of plans and add-ons.
|
||||
* Supports filtering by type, status, visibility, and legacy status.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Plus, Package, Puzzle, Eye, EyeOff, Archive } from 'lucide-react';
|
||||
import { Badge } from '../../components/ui';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface CatalogItem {
|
||||
id: number;
|
||||
type: 'plan' | 'addon';
|
||||
code: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
isPublic: boolean;
|
||||
isLegacy: boolean;
|
||||
priceMonthly?: number;
|
||||
priceYearly?: number;
|
||||
subscriberCount?: number;
|
||||
stripeProductId?: string;
|
||||
}
|
||||
|
||||
export interface CatalogListPanelProps {
|
||||
items: CatalogItem[];
|
||||
selectedId: number | null;
|
||||
onSelect: (item: CatalogItem) => void;
|
||||
onCreatePlan: () => void;
|
||||
onCreateAddon: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
type TypeFilter = 'all' | 'plan' | 'addon';
|
||||
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||
type VisibilityFilter = 'all' | 'public' | 'hidden';
|
||||
type LegacyFilter = 'all' | 'current' | 'legacy';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreatePlan,
|
||||
onCreateAddon,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all');
|
||||
const [legacyFilter, setLegacyFilter] = useState<LegacyFilter>('all');
|
||||
|
||||
// Filter items
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) => {
|
||||
// Type filter
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter === 'active' && !item.isActive) return false;
|
||||
if (statusFilter === 'inactive' && item.isActive) return false;
|
||||
|
||||
// Visibility filter
|
||||
if (visibilityFilter === 'public' && !item.isPublic) return false;
|
||||
if (visibilityFilter === 'hidden' && item.isPublic) return false;
|
||||
|
||||
// Legacy filter
|
||||
if (legacyFilter === 'current' && item.isLegacy) return false;
|
||||
if (legacyFilter === 'legacy' && !item.isLegacy) return false;
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(term) ||
|
||||
item.code.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [items, typeFilter, statusFilter, visibilityFilter, legacyFilter, searchTerm]);
|
||||
|
||||
const formatPrice = (cents?: number): string => {
|
||||
if (cents === undefined || cents === 0) return 'Free';
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||
{/* Header with Create buttons */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onCreatePlan}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Plan
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateAddon}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Add-on
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search by name or code..."
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="type-filter" className="sr-only">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
aria-label="Type"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as TypeFilter)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="plan">Base Plans</option>
|
||||
<option value="addon">Add-ons</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status-filter" className="sr-only">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
aria-label="Status"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="visibility-filter" className="sr-only">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="visibility-filter"
|
||||
aria-label="Visibility"
|
||||
value={visibilityFilter}
|
||||
onChange={(e) => setVisibilityFilter(e.target.value as VisibilityFilter)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Visibility</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="hidden">Hidden</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="legacy-filter" className="sr-only">
|
||||
Legacy
|
||||
</label>
|
||||
<select
|
||||
id="legacy-filter"
|
||||
aria-label="Legacy"
|
||||
value={legacyFilter}
|
||||
onChange={(e) => setLegacyFilter(e.target.value as LegacyFilter)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Versions</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="legacy">Legacy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No items found</p>
|
||||
{searchTerm && (
|
||||
<p className="text-xs mt-1">Try adjusting your search or filters</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredItems.map((item) => (
|
||||
<CatalogListItem
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
isSelected={selectedId === item.id}
|
||||
onSelect={() => onSelect(item)}
|
||||
formatPrice={formatPrice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// List Item Component
|
||||
// =============================================================================
|
||||
|
||||
interface CatalogListItemProps {
|
||||
item: CatalogItem;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
formatPrice: (cents?: number) => string;
|
||||
}
|
||||
|
||||
const CatalogListItem: React.FC<CatalogListItemProps> = ({
|
||||
item,
|
||||
isSelected,
|
||||
onSelect,
|
||||
formatPrice,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`w-full p-4 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-600'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
item.type === 'plan'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{item.type === 'plan' ? (
|
||||
<Package className="w-4 h-4" />
|
||||
) : (
|
||||
<Puzzle className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||
{item.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
{/* Type badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-medium rounded ${
|
||||
item.type === 'plan'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{item.type === 'plan' ? 'Base' : 'Add-on'}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
{!item.isActive && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Visibility badge */}
|
||||
{!item.isPublic && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded flex items-center gap-1">
|
||||
<EyeOff className="w-3 h-3" />
|
||||
Hidden
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Legacy badge */}
|
||||
{item.isLegacy && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded flex items-center gap-1">
|
||||
<Archive className="w-3 h-3" />
|
||||
Legacy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price and subscriber count */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formatPrice(item.priceMonthly)}/mo
|
||||
</span>
|
||||
{item.subscriberCount !== undefined && (
|
||||
<span>{item.subscriberCount} subscribers</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
252
frontend/src/billing/components/FeaturePicker.tsx
Normal file
252
frontend/src/billing/components/FeaturePicker.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* FeaturePicker Component
|
||||
*
|
||||
* A searchable picker for selecting features to include in a plan or version.
|
||||
* Features are grouped by type (boolean capabilities vs integer limits).
|
||||
* Features are loaded dynamically from the billing API.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Check, Sliders, Search, X } from 'lucide-react';
|
||||
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
||||
|
||||
export interface FeaturePickerProps {
|
||||
/** Available features from the API */
|
||||
features: Feature[];
|
||||
/** Currently selected features with their values */
|
||||
selectedFeatures: PlanFeatureWrite[];
|
||||
/** Callback when selection changes */
|
||||
onChange: (features: PlanFeatureWrite[]) => void;
|
||||
/** Optional: Show compact view */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
features,
|
||||
selectedFeatures,
|
||||
onChange,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Group features by type
|
||||
const { booleanFeatures, integerFeatures } = useMemo(() => {
|
||||
const boolean = features.filter((f) => f.feature_type === 'boolean');
|
||||
const integer = features.filter((f) => f.feature_type === 'integer');
|
||||
return { booleanFeatures: boolean, integerFeatures: integer };
|
||||
}, [features]);
|
||||
|
||||
// Filter by search term
|
||||
const filteredBooleanFeatures = useMemo(() => {
|
||||
if (!searchTerm) return booleanFeatures;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return booleanFeatures.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(term) ||
|
||||
f.code.toLowerCase().includes(term) ||
|
||||
f.description?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [booleanFeatures, searchTerm]);
|
||||
|
||||
const filteredIntegerFeatures = useMemo(() => {
|
||||
if (!searchTerm) return integerFeatures;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return integerFeatures.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(term) ||
|
||||
f.code.toLowerCase().includes(term) ||
|
||||
f.description?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [integerFeatures, searchTerm]);
|
||||
|
||||
const hasNoResults =
|
||||
searchTerm && filteredBooleanFeatures.length === 0 && filteredIntegerFeatures.length === 0;
|
||||
|
||||
// Check if a feature is selected
|
||||
const isSelected = (code: string): boolean => {
|
||||
return selectedFeatures.some((f) => f.feature_code === code);
|
||||
};
|
||||
|
||||
// Get selected feature data
|
||||
const getSelectedFeature = (code: string): PlanFeatureWrite | undefined => {
|
||||
return selectedFeatures.find((f) => f.feature_code === code);
|
||||
};
|
||||
|
||||
// Toggle boolean feature selection
|
||||
const toggleBooleanFeature = (code: string) => {
|
||||
if (isSelected(code)) {
|
||||
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
||||
} else {
|
||||
onChange([
|
||||
...selectedFeatures,
|
||||
{ feature_code: code, bool_value: true, int_value: null },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle integer feature selection
|
||||
const toggleIntegerFeature = (code: string) => {
|
||||
if (isSelected(code)) {
|
||||
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
||||
} else {
|
||||
onChange([
|
||||
...selectedFeatures,
|
||||
{ feature_code: code, bool_value: null, int_value: 0 },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Update integer feature value
|
||||
const updateIntegerValue = (code: string, value: number) => {
|
||||
onChange(
|
||||
selectedFeatures.map((f) =>
|
||||
f.feature_code === code ? { ...f, int_value: value } : f
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Box */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search features..."
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Results Message */}
|
||||
{hasNoResults && (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No features found matching "{searchTerm}"</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boolean Features (Capabilities) */}
|
||||
{filteredBooleanFeatures.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" /> Capabilities
|
||||
</h4>
|
||||
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
||||
{filteredBooleanFeatures.map((feature) => {
|
||||
const selected = isSelected(feature.code);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={feature.id}
|
||||
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleBooleanFeature(feature.code)}
|
||||
aria-label={feature.name}
|
||||
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{feature.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integer Features (Limits & Quotas) */}
|
||||
{filteredIntegerFeatures.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4" /> Limits & Quotas
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Set to 0 for unlimited. Uncheck to exclude from plan.
|
||||
</p>
|
||||
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||
{filteredIntegerFeatures.map((feature) => {
|
||||
const selectedFeature = getSelectedFeature(feature.code);
|
||||
const selected = !!selectedFeature;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg ${
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleIntegerFeature(feature.code)}
|
||||
aria-label={feature.name}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
||||
{feature.name}
|
||||
</span>
|
||||
</label>
|
||||
{selected && (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={selectedFeature?.int_value || 0}
|
||||
onChange={(e) =>
|
||||
updateIntegerValue(feature.code, parseInt(e.target.value) || 0)
|
||||
}
|
||||
aria-label={`${feature.name} limit value`}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!searchTerm && features.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No features defined yet.</p>
|
||||
<p className="text-xs mt-1">Add features in the Features Library tab first.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
766
frontend/src/billing/components/PlanDetailPanel.tsx
Normal file
766
frontend/src/billing/components/PlanDetailPanel.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* PlanDetailPanel Component
|
||||
*
|
||||
* Detail view for a selected plan or add-on, shown in the main panel
|
||||
* of the master-detail layout.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
Pencil,
|
||||
Copy,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Users,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Archive,
|
||||
} from 'lucide-react';
|
||||
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
|
||||
import {
|
||||
usePlanVersionSubscribers,
|
||||
useDeletePlan,
|
||||
useDeletePlanVersion,
|
||||
useMarkVersionLegacy,
|
||||
useForceUpdatePlanVersion,
|
||||
formatCentsToDollars,
|
||||
type PlanWithVersions,
|
||||
type PlanVersion,
|
||||
type AddOnProduct,
|
||||
} from '../../hooks/useBillingAdmin';
|
||||
import { useCurrentUser } from '../../hooks/useAuth';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface PlanDetailPanelProps {
|
||||
plan: PlanWithVersions | null;
|
||||
addon: AddOnProduct | null;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onCreateVersion: () => void;
|
||||
onEditVersion: (version: PlanVersion) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||
plan,
|
||||
addon,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onCreateVersion,
|
||||
onEditVersion,
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['overview', 'pricing', 'features'])
|
||||
);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [showForcePushModal, setShowForcePushModal] = useState(false);
|
||||
const [forcePushConfirmText, setForcePushConfirmText] = useState('');
|
||||
const [forcePushError, setForcePushError] = useState<string | null>(null);
|
||||
const [forcePushSuccess, setForcePushSuccess] = useState<string | null>(null);
|
||||
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const isSuperuser = currentUser?.is_superuser ?? false;
|
||||
|
||||
const deletePlanMutation = useDeletePlan();
|
||||
const deleteVersionMutation = useDeletePlanVersion();
|
||||
const markLegacyMutation = useMarkVersionLegacy();
|
||||
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||||
|
||||
if (!plan && !addon) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Select a plan or add-on from the catalog</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const activeVersion = plan?.active_version;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!plan) return;
|
||||
|
||||
const expectedText = `DELETE ${plan.code}`;
|
||||
if (deleteConfirmText !== expectedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePlanMutation.mutateAsync(plan.id);
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirmText('');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForcePush = async () => {
|
||||
if (!plan || !activeVersion) return;
|
||||
|
||||
const expectedText = `FORCE PUSH ${plan.code}`;
|
||||
if (forcePushConfirmText !== expectedText) {
|
||||
setForcePushError('Please type the confirmation text exactly.');
|
||||
return;
|
||||
}
|
||||
|
||||
setForcePushError(null);
|
||||
|
||||
try {
|
||||
const result = await forceUpdateMutation.mutateAsync({
|
||||
id: activeVersion.id,
|
||||
confirm: true,
|
||||
// Pass current version data to ensure it's updated in place
|
||||
name: activeVersion.name,
|
||||
});
|
||||
|
||||
if ('version' in result) {
|
||||
setForcePushSuccess(
|
||||
`Successfully pushed changes to ${result.affected_count} subscriber(s).`
|
||||
);
|
||||
setTimeout(() => {
|
||||
setShowForcePushModal(false);
|
||||
setForcePushConfirmText('');
|
||||
setForcePushSuccess(null);
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Failed to force push';
|
||||
setForcePushError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// Render Plan Detail
|
||||
if (plan) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{plan.name}
|
||||
</h2>
|
||||
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||
{plan.code}
|
||||
</span>
|
||||
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
|
||||
</div>
|
||||
{plan.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
|
||||
)}
|
||||
{activeVersion && (
|
||||
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{activeVersion.price_monthly_cents === 0
|
||||
? 'Free'
|
||||
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>Version {activeVersion.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={onDuplicate}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreateVersion}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Version
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Overview Section */}
|
||||
<CollapsibleSection
|
||||
title="Overview"
|
||||
isExpanded={expandedSections.has('overview')}
|
||||
onToggle={() => toggleSection('overview')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{plan.is_active ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Pricing Section */}
|
||||
{activeVersion && (
|
||||
<CollapsibleSection
|
||||
title="Pricing"
|
||||
isExpanded={expandedSections.has('pricing')}
|
||||
onToggle={() => toggleSection('pricing')}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${formatCentsToDollars(activeVersion.price_monthly_cents)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${formatCentsToDollars(activeVersion.price_yearly_cents)}
|
||||
</p>
|
||||
{activeVersion.price_yearly_cents > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{activeVersion.trial_days} days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Transaction Fees Section */}
|
||||
{activeVersion && (
|
||||
<CollapsibleSection
|
||||
title="Transaction Fees"
|
||||
isExpanded={expandedSections.has('fees')}
|
||||
onToggle={() => toggleSection('fees')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{activeVersion.transaction_fee_percent}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Features Section */}
|
||||
{activeVersion && activeVersion.features.length > 0 && (
|
||||
<CollapsibleSection
|
||||
title={`Features (${activeVersion.features.length})`}
|
||||
isExpanded={expandedSections.has('features')}
|
||||
onToggle={() => toggleSection('features')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{activeVersion.features.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
|
||||
>
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{f.feature.name}
|
||||
</span>
|
||||
{f.int_value !== null && (
|
||||
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
|
||||
{f.int_value === 0 ? 'Unlimited' : f.int_value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Stripe Section */}
|
||||
{activeVersion &&
|
||||
(activeVersion.stripe_product_id ||
|
||||
activeVersion.stripe_price_id_monthly ||
|
||||
activeVersion.stripe_price_id_yearly) && (
|
||||
<CollapsibleSection
|
||||
title="Stripe Integration"
|
||||
isExpanded={expandedSections.has('stripe')}
|
||||
onToggle={() => toggleSection('stripe')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{activeVersion.stripe_product_id && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{activeVersion.stripe_product_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{activeVersion.stripe_price_id_monthly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Monthly Price:
|
||||
</span>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{activeVersion.stripe_price_id_monthly}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{activeVersion.stripe_price_id_yearly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Yearly Price:
|
||||
</span>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{activeVersion.stripe_price_id_yearly}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{!activeVersion.stripe_product_id && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Versions Section */}
|
||||
<CollapsibleSection
|
||||
title={`Versions (${plan.versions.length})`}
|
||||
isExpanded={expandedSections.has('versions')}
|
||||
onToggle={() => toggleSection('versions')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{plan.versions.map((version) => (
|
||||
<VersionRow
|
||||
key={version.id}
|
||||
version={version}
|
||||
isActive={!version.is_legacy}
|
||||
onEdit={() => onEditVersion(version)}
|
||||
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
|
||||
onDelete={() => deleteVersionMutation.mutate(version.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<CollapsibleSection
|
||||
title="Danger Zone"
|
||||
isExpanded={expandedSections.has('danger')}
|
||||
onToggle={() => toggleSection('danger')}
|
||||
variant="danger"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Force Push to Subscribers - Superuser Only */}
|
||||
{isSuperuser && activeVersion && plan.total_subscribers > 0 && (
|
||||
<div className="p-4 border border-orange-200 dark:border-orange-800 rounded-lg bg-orange-50 dark:bg-orange-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-orange-800 dark:text-orange-200 mb-1">
|
||||
Force Push Changes to All Subscribers
|
||||
</h4>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300 mb-3">
|
||||
This will modify the current plan version in place, immediately affecting
|
||||
all {plan.total_subscribers} active subscriber(s). This bypasses grandfathering
|
||||
and cannot be undone. Changes to pricing, features, and limits will take
|
||||
effect immediately.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForcePushModal(true);
|
||||
setForcePushError(null);
|
||||
setForcePushSuccess(null);
|
||||
setForcePushConfirmText('');
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Force Push to Subscribers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Plan */}
|
||||
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
|
||||
cannot be deleted.
|
||||
</p>
|
||||
{plan.total_subscribers > 0 ? (
|
||||
<Alert
|
||||
variant="warning"
|
||||
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
title="Delete Plan"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert
|
||||
variant="error"
|
||||
message="This action cannot be undone. This will permanently delete the plan and all its versions."
|
||||
/>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
To confirm, type <strong>DELETE {plan.code}</strong> below:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(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"
|
||||
placeholder={`DELETE ${plan.code}`}
|
||||
/>
|
||||
</div>
|
||||
<ModalFooter
|
||||
onCancel={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
submitText="Delete Plan"
|
||||
submitVariant="danger"
|
||||
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
|
||||
isLoading={deletePlanMutation.isPending}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Force Push Confirmation Modal */}
|
||||
{showForcePushModal && activeVersion && (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setShowForcePushModal(false);
|
||||
setForcePushConfirmText('');
|
||||
setForcePushError(null);
|
||||
setForcePushSuccess(null);
|
||||
}}
|
||||
title="Force Push to All Subscribers"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{forcePushSuccess ? (
|
||||
<Alert variant="success" message={forcePushSuccess} />
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
variant="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>DANGER: This action affects paying customers!</strong>
|
||||
<ul className="mt-2 ml-4 list-disc text-sm">
|
||||
<li>All {plan.total_subscribers} subscriber(s) will be affected immediately</li>
|
||||
<li>Changes to pricing will apply to future billing cycles</li>
|
||||
<li>Feature and limit changes take effect immediately</li>
|
||||
<li>This bypasses grandfathering protection</li>
|
||||
<li>This action cannot be undone</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<strong>Current version:</strong> v{activeVersion.version} - {activeVersion.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Price:</strong> ${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{forcePushError && (
|
||||
<Alert variant="error" message={forcePushError} />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
To confirm this dangerous action, type <strong>FORCE PUSH {plan.code}</strong> below:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={forcePushConfirmText}
|
||||
onChange={(e) => setForcePushConfirmText(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"
|
||||
placeholder={`FORCE PUSH ${plan.code}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!forcePushSuccess && (
|
||||
<ModalFooter
|
||||
onCancel={() => {
|
||||
setShowForcePushModal(false);
|
||||
setForcePushConfirmText('');
|
||||
setForcePushError(null);
|
||||
}}
|
||||
submitText="Force Push Changes"
|
||||
submitVariant="danger"
|
||||
isDisabled={forcePushConfirmText !== `FORCE PUSH ${plan.code}`}
|
||||
isLoading={forceUpdateMutation.isPending}
|
||||
onSubmit={handleForcePush}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render Add-on Detail
|
||||
if (addon) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{addon.name}
|
||||
</h2>
|
||||
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
|
||||
{addon.code}
|
||||
</span>
|
||||
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
|
||||
</div>
|
||||
{addon.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${formatCentsToDollars(addon.price_monthly_cents)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
${formatCentsToDollars(addon.price_one_time_cents)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Collapsible Section
|
||||
// =============================================================================
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg ${
|
||||
variant === 'danger'
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between p-4 text-left ${
|
||||
variant === 'danger'
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{title}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Version Row
|
||||
// =============================================================================
|
||||
|
||||
interface VersionRowProps {
|
||||
version: PlanVersion;
|
||||
isActive: boolean;
|
||||
onEdit: () => void;
|
||||
onMarkLegacy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const VersionRow: React.FC<VersionRowProps> = ({
|
||||
version,
|
||||
isActive,
|
||||
onEdit,
|
||||
onMarkLegacy,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${
|
||||
version.is_legacy
|
||||
? 'bg-gray-50 dark:bg-gray-700/50'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
|
||||
{version.is_legacy && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
|
||||
Legacy
|
||||
</span>
|
||||
)}
|
||||
{!version.is_public && !version.is_legacy && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||
Hidden
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Edit version"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
{!version.is_legacy && version.subscriber_count === 0 && (
|
||||
<button
|
||||
onClick={onMarkLegacy}
|
||||
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
|
||||
title="Mark as legacy"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{version.subscriber_count === 0 && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
title="Delete version"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
992
frontend/src/billing/components/PlanEditorWizard.tsx
Normal file
992
frontend/src/billing/components/PlanEditorWizard.tsx
Normal file
@@ -0,0 +1,992 @@
|
||||
/**
|
||||
* PlanEditorWizard Component
|
||||
*
|
||||
* A multi-step wizard for creating or editing subscription plans.
|
||||
* Replaces the large form in PlanModal with guided step-by-step editing.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Basics - Name, code, description, active status
|
||||
* 2. Pricing - Monthly/yearly prices, trial days, transaction fees
|
||||
* 3. Features - Feature picker for capabilities and limits
|
||||
* 4. Display - Visibility, marketing features, Stripe integration
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Package,
|
||||
DollarSign,
|
||||
Check,
|
||||
Star,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Modal, Alert } from '../../components/ui';
|
||||
import { FeaturePicker } from './FeaturePicker';
|
||||
import {
|
||||
useFeatures,
|
||||
useAddOnProducts,
|
||||
useCreatePlan,
|
||||
useCreatePlanVersion,
|
||||
useUpdatePlan,
|
||||
useUpdatePlanVersion,
|
||||
useForceUpdatePlanVersion,
|
||||
isForceUpdateConfirmRequired,
|
||||
type PlanFeatureWrite,
|
||||
} from '../../hooks/useBillingAdmin';
|
||||
import { useCurrentUser } from '../../hooks/useAuth';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface PlanEditorWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: {
|
||||
id?: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
version?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
price_monthly_cents?: number;
|
||||
price_yearly_cents?: number;
|
||||
transaction_fee_percent?: string | number;
|
||||
transaction_fee_fixed_cents?: number;
|
||||
trial_days?: number;
|
||||
is_public?: boolean;
|
||||
is_most_popular?: boolean;
|
||||
show_price?: boolean;
|
||||
marketing_features?: string[];
|
||||
stripe_product_id?: string;
|
||||
stripe_price_id_monthly?: string;
|
||||
stripe_price_id_yearly?: string;
|
||||
features?: Array<{
|
||||
feature: { code: string };
|
||||
bool_value: boolean | null;
|
||||
int_value: number | null;
|
||||
}>;
|
||||
subscriber_count?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type WizardStep = 'basics' | 'pricing' | 'features' | 'display';
|
||||
|
||||
interface WizardFormData {
|
||||
// Plan fields
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
// Version fields
|
||||
version_name: string;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents: number;
|
||||
transaction_fee_percent: number;
|
||||
transaction_fee_fixed_cents: number;
|
||||
trial_days: number;
|
||||
is_public: boolean;
|
||||
is_most_popular: boolean;
|
||||
show_price: boolean;
|
||||
marketing_features: string[];
|
||||
stripe_product_id: string;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
selectedFeatures: PlanFeatureWrite[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
initialData,
|
||||
}) => {
|
||||
const { data: features, isLoading: featuresLoading } = useFeatures();
|
||||
const { data: addons } = useAddOnProducts();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const createPlanMutation = useCreatePlan();
|
||||
const createVersionMutation = useCreatePlanVersion();
|
||||
const updatePlanMutation = useUpdatePlan();
|
||||
const updateVersionMutation = useUpdatePlanVersion();
|
||||
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||||
|
||||
const isNewPlan = mode === 'create';
|
||||
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
|
||||
const isSuperuser = currentUser?.role === 'superuser';
|
||||
|
||||
// Force update state (for updating without creating new version)
|
||||
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
|
||||
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
|
||||
const [newMarketingFeature, setNewMarketingFeature] = useState('');
|
||||
|
||||
// Form data
|
||||
const [formData, setFormData] = useState<WizardFormData>(() => ({
|
||||
// Plan fields
|
||||
code: initialData?.code || '',
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
display_order: initialData?.display_order || 0,
|
||||
is_active: initialData?.is_active ?? true,
|
||||
// Version fields
|
||||
version_name: initialData?.version?.name || '',
|
||||
price_monthly_cents: initialData?.version?.price_monthly_cents || 0,
|
||||
price_yearly_cents: initialData?.version?.price_yearly_cents || 0,
|
||||
transaction_fee_percent:
|
||||
typeof initialData?.version?.transaction_fee_percent === 'string'
|
||||
? parseFloat(initialData.version.transaction_fee_percent)
|
||||
: initialData?.version?.transaction_fee_percent || 4.0,
|
||||
transaction_fee_fixed_cents: initialData?.version?.transaction_fee_fixed_cents || 40,
|
||||
trial_days: initialData?.version?.trial_days || 14,
|
||||
is_public: initialData?.version?.is_public ?? true,
|
||||
is_most_popular: initialData?.version?.is_most_popular || false,
|
||||
show_price: initialData?.version?.show_price ?? true,
|
||||
marketing_features: initialData?.version?.marketing_features || [],
|
||||
stripe_product_id: initialData?.version?.stripe_product_id || '',
|
||||
stripe_price_id_monthly: initialData?.version?.stripe_price_id_monthly || '',
|
||||
stripe_price_id_yearly: initialData?.version?.stripe_price_id_yearly || '',
|
||||
selectedFeatures:
|
||||
initialData?.version?.features?.map((f) => ({
|
||||
feature_code: f.feature.code,
|
||||
bool_value: f.bool_value,
|
||||
int_value: f.int_value,
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
// Validation errors
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Wizard steps configuration
|
||||
const steps: Array<{ id: WizardStep; label: string; icon: React.ElementType }> = [
|
||||
{ id: 'basics', label: 'Basics', icon: Package },
|
||||
{ id: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||
{ id: 'features', label: 'Features', icon: Check },
|
||||
{ id: 'display', label: 'Display', icon: Star },
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
|
||||
// Validation
|
||||
const validateBasics = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.code.trim()) newErrors.code = 'Plan code is required';
|
||||
if (!formData.name.trim()) newErrors.name = 'Plan name is required';
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validatePricing = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100) {
|
||||
newErrors.transaction_fee_percent = 'Fee must be between 0 and 100';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Navigation
|
||||
const canProceed = useMemo(() => {
|
||||
if (currentStep === 'basics') {
|
||||
return formData.code.trim() !== '' && formData.name.trim() !== '';
|
||||
}
|
||||
return true;
|
||||
}, [currentStep, formData.code, formData.name]);
|
||||
|
||||
const goNext = () => {
|
||||
if (currentStep === 'basics' && !validateBasics()) return;
|
||||
if (currentStep === 'pricing' && !validatePricing()) return;
|
||||
|
||||
if (!isLastStep) {
|
||||
setCurrentStep(steps[currentStepIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStep(steps[currentStepIndex - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const goToStep = (stepId: WizardStep) => {
|
||||
// Only allow navigating to visited steps or current step
|
||||
const targetIndex = steps.findIndex((s) => s.id === stepId);
|
||||
if (targetIndex <= currentStepIndex || canProceed) {
|
||||
setCurrentStep(stepId);
|
||||
}
|
||||
};
|
||||
|
||||
// Form handlers
|
||||
const updateCode = (value: string) => {
|
||||
// Sanitize: lowercase, no spaces, only alphanumeric and hyphens/underscores
|
||||
const sanitized = value.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
setFormData((prev) => ({ ...prev, code: sanitized }));
|
||||
};
|
||||
|
||||
const addMarketingFeature = () => {
|
||||
if (newMarketingFeature.trim()) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
marketing_features: [...prev.marketing_features, newMarketingFeature.trim()],
|
||||
}));
|
||||
setNewMarketingFeature('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeMarketingFeature = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
marketing_features: prev.marketing_features.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// Submit
|
||||
const handleSubmit = async () => {
|
||||
if (!validateBasics() || !validatePricing()) return;
|
||||
|
||||
try {
|
||||
if (isNewPlan) {
|
||||
// Create Plan first
|
||||
await createPlanMutation.mutateAsync({
|
||||
code: formData.code,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
display_order: formData.display_order,
|
||||
is_active: formData.is_active,
|
||||
});
|
||||
|
||||
// Create first version
|
||||
await createVersionMutation.mutateAsync({
|
||||
plan_code: formData.code,
|
||||
name: formData.version_name || `${formData.name} v1`,
|
||||
is_public: formData.is_public,
|
||||
price_monthly_cents: formData.price_monthly_cents,
|
||||
price_yearly_cents: formData.price_yearly_cents,
|
||||
transaction_fee_percent: formData.transaction_fee_percent,
|
||||
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||
trial_days: formData.trial_days,
|
||||
is_most_popular: formData.is_most_popular,
|
||||
show_price: formData.show_price,
|
||||
marketing_features: formData.marketing_features,
|
||||
stripe_product_id: formData.stripe_product_id,
|
||||
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||
features: formData.selectedFeatures,
|
||||
});
|
||||
} else if (initialData?.id) {
|
||||
// Update plan
|
||||
await updatePlanMutation.mutateAsync({
|
||||
id: initialData.id,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
display_order: formData.display_order,
|
||||
is_active: formData.is_active,
|
||||
});
|
||||
|
||||
// Update version if exists
|
||||
if (initialData?.version?.id) {
|
||||
await updateVersionMutation.mutateAsync({
|
||||
id: initialData.version.id,
|
||||
name: formData.version_name,
|
||||
is_public: formData.is_public,
|
||||
price_monthly_cents: formData.price_monthly_cents,
|
||||
price_yearly_cents: formData.price_yearly_cents,
|
||||
transaction_fee_percent: formData.transaction_fee_percent,
|
||||
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||
trial_days: formData.trial_days,
|
||||
is_most_popular: formData.is_most_popular,
|
||||
show_price: formData.show_price,
|
||||
marketing_features: formData.marketing_features,
|
||||
stripe_product_id: formData.stripe_product_id,
|
||||
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||
features: formData.selectedFeatures,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Force update handler (updates existing version without creating new one)
|
||||
const handleForceUpdate = async () => {
|
||||
if (!initialData?.version?.id) return;
|
||||
|
||||
try {
|
||||
setForceUpdateError(null);
|
||||
|
||||
// First call without confirm to get affected subscriber count
|
||||
const response = await forceUpdateMutation.mutateAsync({
|
||||
id: initialData.version.id,
|
||||
name: formData.version_name,
|
||||
is_public: formData.is_public,
|
||||
price_monthly_cents: formData.price_monthly_cents,
|
||||
price_yearly_cents: formData.price_yearly_cents,
|
||||
transaction_fee_percent: formData.transaction_fee_percent,
|
||||
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||
trial_days: formData.trial_days,
|
||||
is_most_popular: formData.is_most_popular,
|
||||
show_price: formData.show_price,
|
||||
marketing_features: formData.marketing_features,
|
||||
stripe_product_id: formData.stripe_product_id,
|
||||
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||
features: formData.selectedFeatures,
|
||||
confirm: true, // Confirm immediately since user already acknowledged
|
||||
});
|
||||
|
||||
// If successful, close the modal
|
||||
if (!isForceUpdateConfirmRequired(response)) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to force update plan:', error);
|
||||
setForceUpdateError('Failed to update plan. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
createPlanMutation.isPending ||
|
||||
createVersionMutation.isPending ||
|
||||
updatePlanMutation.isPending ||
|
||||
updateVersionMutation.isPending ||
|
||||
forceUpdateMutation.isPending;
|
||||
|
||||
// Derived values for display
|
||||
const monthlyEquivalent = formData.price_yearly_cents > 0
|
||||
? (formData.price_yearly_cents / 12 / 100).toFixed(2)
|
||||
: null;
|
||||
|
||||
const transactionFeeExample = () => {
|
||||
const percent = formData.transaction_fee_percent / 100;
|
||||
const fixed = formData.transaction_fee_fixed_cents / 100;
|
||||
const total = (100 * percent + fixed).toFixed(2);
|
||||
return `On a $100 transaction: $${total} fee`;
|
||||
};
|
||||
|
||||
// Fee validation warning
|
||||
const feeError =
|
||||
formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100
|
||||
? 'Fee must be between 0 and 100'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isNewPlan ? 'Create New Plan' : `Edit ${initialData?.name || 'Plan'}`}
|
||||
size="4xl"
|
||||
>
|
||||
{/* Grandfathering Warning */}
|
||||
{hasSubscribers && !showForceUpdateConfirm && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mb-4"
|
||||
message={
|
||||
<>
|
||||
This version has <strong>{initialData?.version?.subscriber_count}</strong> active
|
||||
subscriber(s). Saving will create a new version (grandfathering). Existing subscribers
|
||||
keep their current plan.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Force Update Confirmation Dialog */}
|
||||
{showForceUpdateConfirm && (
|
||||
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
Warning: This will affect existing customers
|
||||
</h4>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
|
||||
You are about to update this plan version <strong>in place</strong>. This will immediately
|
||||
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
|
||||
subscriber(s). This action cannot be undone.
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||
Only use this for correcting errors or minor adjustments. For significant changes,
|
||||
use the standard save which creates a new version and grandfathers existing subscribers.
|
||||
</p>
|
||||
{forceUpdateError && (
|
||||
<Alert variant="error" message={forceUpdateError} className="mb-3" />
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForceUpdateConfirm(false);
|
||||
setForceUpdateError(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleForceUpdate}
|
||||
disabled={isLoading}
|
||||
className="inline-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"
|
||||
>
|
||||
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Yes, Update All Subscribers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep;
|
||||
const isCompleted = index < currentStepIndex;
|
||||
const StepIcon = step.icon;
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={`h-px w-8 ${
|
||||
isCompleted ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(step.id)}
|
||||
aria-label={step.label}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: isCompleted
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<StepIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{step.label}</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Main Form Area */}
|
||||
<div className="flex-1 max-h-[60vh] overflow-y-auto">
|
||||
{/* Step 1: Basics */}
|
||||
{currentStep === 'basics' && (
|
||||
<div className="space-y-4 p-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="plan-code"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Plan Code *
|
||||
</label>
|
||||
<input
|
||||
id="plan-code"
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => updateCode(e.target.value)}
|
||||
required
|
||||
disabled={!isNewPlan}
|
||||
placeholder="e.g., starter, pro, enterprise"
|
||||
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 disabled:opacity-50"
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.code}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="plan-name"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Display Name *
|
||||
</label>
|
||||
<input
|
||||
id="plan-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
required
|
||||
placeholder="e.g., Starter, Professional, Enterprise"
|
||||
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"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-red-500 mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="plan-description"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="plan-description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="Brief description of this plan..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
|
||||
}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Active (available for purchase)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Pricing */}
|
||||
{currentStep === 'pricing' && (
|
||||
<div className="space-y-6 p-1">
|
||||
{/* Subscription Pricing */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" /> Subscription Pricing
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="price-monthly"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Monthly Price ($)
|
||||
</label>
|
||||
<input
|
||||
id="price-monthly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price_monthly_cents / 100}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
price_monthly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="price-yearly"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Yearly Price ($)
|
||||
</label>
|
||||
<input
|
||||
id="price-yearly"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.price_yearly_cents / 100}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
price_yearly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
{monthlyEquivalent && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
=${monthlyEquivalent}/mo equivalent
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="trial-days"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Trial Days
|
||||
</label>
|
||||
<input
|
||||
id="trial-days"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.trial_days}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
trial_days: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Fees */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Transaction Fees
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="fee-percent"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Fee Percentage (%)
|
||||
</label>
|
||||
<input
|
||||
id="fee-percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.transaction_fee_percent}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
transaction_fee_percent: parseFloat(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
{feeError && (
|
||||
<p className="text-xs text-red-500 mt-1">{feeError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="fee-fixed"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Fixed Fee (cents)
|
||||
</label>
|
||||
<input
|
||||
id="fee-fixed"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.transaction_fee_fixed_cents}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
transaction_fee_fixed_cents: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{transactionFeeExample()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Features */}
|
||||
{currentStep === 'features' && (
|
||||
<div className="p-1">
|
||||
{featuresLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
) : (
|
||||
<FeaturePicker
|
||||
features={features || []}
|
||||
selectedFeatures={formData.selectedFeatures}
|
||||
onChange={(selected) =>
|
||||
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Display */}
|
||||
{currentStep === 'display' && (
|
||||
<div className="space-y-6 p-1">
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Visibility Settings
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_public}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, is_public: e.target.checked }))
|
||||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Show on pricing page
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_most_popular}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, is_most_popular: e.target.checked }))
|
||||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
"Most Popular" badge
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.show_price}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, show_price: e.target.checked }))
|
||||
}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Display price</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Features */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Marketing Feature List
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Bullet points shown on pricing page. Separate from actual feature access.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{formData.marketing_features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{feature}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMarketingFeature(index)}
|
||||
className="text-gray-400 hover:text-red-500 p-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMarketingFeature}
|
||||
onChange={(e) => setNewMarketingFeature(e.target.value)}
|
||||
onKeyPress={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), addMarketingFeature())
|
||||
}
|
||||
placeholder="e.g., Unlimited appointments"
|
||||
className="flex-1 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 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMarketingFeature}
|
||||
className="px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stripe */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||
Stripe Integration
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Product ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.stripe_product_id}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, stripe_product_id: e.target.value }))
|
||||
}
|
||||
placeholder="prod_..."
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Monthly Price ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.stripe_price_id_monthly}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
stripe_price_id_monthly: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="price_..."
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Yearly Price ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.stripe_price_id_yearly}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, stripe_price_id_yearly: e.target.value }))
|
||||
}
|
||||
placeholder="price_..."
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Summary Panel */}
|
||||
<div className="w-64 border-l border-gray-200 dark:border-gray-700 pl-6 hidden lg:block">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Plan Summary</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Name:</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{formData.name || '(not set)'}
|
||||
</p>
|
||||
</div>
|
||||
{formData.price_monthly_cents > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Price:</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
${(formData.price_monthly_cents / 100).toFixed(2)}/mo
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Features:</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{formData.selectedFeatures.length} feature
|
||||
{formData.selectedFeatures.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{formData.is_active ? 'Active' : 'Inactive'}
|
||||
{formData.is_public ? ', Public' : ', Hidden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
{!isFirstStep && !showForceUpdateConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!showForceUpdateConfirm && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{!isLastStep ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!canProceed}
|
||||
className="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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Force Update button - only for superusers editing plans with subscribers */}
|
||||
{hasSubscribers && isSuperuser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForceUpdateConfirm(true)}
|
||||
disabled={isLoading || !canProceed}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Update Without Versioning
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !canProceed}
|
||||
className="inline-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"
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Tests for CatalogListPanel Component
|
||||
*
|
||||
* TDD: Tests for the sidebar catalog list with search and filtering.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CatalogListPanel, type CatalogItem } from '../CatalogListPanel';
|
||||
|
||||
// Sample plan data
|
||||
const mockPlans: CatalogItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'plan',
|
||||
code: 'free',
|
||||
name: 'Free',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isLegacy: false,
|
||||
priceMonthly: 0,
|
||||
priceYearly: 0,
|
||||
subscriberCount: 50,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'plan',
|
||||
code: 'starter',
|
||||
name: 'Starter',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isLegacy: false,
|
||||
priceMonthly: 2900,
|
||||
priceYearly: 29000,
|
||||
subscriberCount: 25,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'plan',
|
||||
code: 'pro',
|
||||
name: 'Professional',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isLegacy: false,
|
||||
priceMonthly: 7900,
|
||||
priceYearly: 79000,
|
||||
subscriberCount: 10,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'plan',
|
||||
code: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
isActive: true,
|
||||
isPublic: false, // Hidden plan
|
||||
isLegacy: false,
|
||||
priceMonthly: 19900,
|
||||
priceYearly: 199000,
|
||||
subscriberCount: 3,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'plan',
|
||||
code: 'legacy_pro',
|
||||
name: 'Pro (Legacy)',
|
||||
isActive: false, // Inactive
|
||||
isPublic: false,
|
||||
isLegacy: true,
|
||||
priceMonthly: 4900,
|
||||
priceYearly: 49000,
|
||||
subscriberCount: 15,
|
||||
},
|
||||
];
|
||||
|
||||
// Sample add-on data
|
||||
const mockAddons: CatalogItem[] = [
|
||||
{
|
||||
id: 101,
|
||||
type: 'addon',
|
||||
code: 'sms_pack',
|
||||
name: 'SMS Credits Pack',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isLegacy: false,
|
||||
priceMonthly: 500,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
type: 'addon',
|
||||
code: 'api_access',
|
||||
name: 'API Access',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isLegacy: false,
|
||||
priceMonthly: 2000,
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: 'addon',
|
||||
code: 'old_addon',
|
||||
name: 'Deprecated Add-on',
|
||||
isActive: false,
|
||||
isPublic: false,
|
||||
isLegacy: true,
|
||||
priceMonthly: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
const allItems = [...mockPlans, ...mockAddons];
|
||||
|
||||
describe('CatalogListPanel', () => {
|
||||
const defaultProps = {
|
||||
items: allItems,
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
onCreatePlan: vi.fn(),
|
||||
onCreateAddon: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders all items by default', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Should show all plans
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
|
||||
// Should show all addons
|
||||
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows item code as badge', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('free')).toBeInTheDocument();
|
||||
expect(screen.getByText('starter')).toBeInTheDocument();
|
||||
expect(screen.getByText('sms_pack')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows price for items', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Free plan shows "Free/mo" - use getAllByText since "free" appears multiple times
|
||||
const freeElements = screen.getAllByText(/free/i);
|
||||
expect(freeElements.length).toBeGreaterThan(0);
|
||||
|
||||
// Starter plan shows $29.00/mo
|
||||
expect(screen.getByText(/\$29/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows type badges for plans and add-ons', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Should have Base Plan badges for plans
|
||||
const baseBadges = screen.getAllByText(/base/i);
|
||||
expect(baseBadges.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have Add-on badges
|
||||
const addonBadges = screen.getAllByText(/add-on/i);
|
||||
expect(addonBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows status badges for inactive items', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Legacy plan should show inactive badge
|
||||
const inactiveBadges = screen.getAllByText(/inactive/i);
|
||||
expect(inactiveBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows legacy badge for legacy items', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const legacyBadges = screen.getAllByText(/legacy/i);
|
||||
expect(legacyBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows hidden badge for non-public items', () => {
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const hiddenBadges = screen.getAllByText(/hidden/i);
|
||||
expect(hiddenBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Filtering', () => {
|
||||
it('filters to show only base plans', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Click the "Base Plans" filter
|
||||
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||
await user.selectOptions(typeFilter, 'plan');
|
||||
|
||||
// Should show plans
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show addons
|
||||
expect(screen.queryByText('SMS Credits Pack')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to show only add-ons', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||
await user.selectOptions(typeFilter, 'addon');
|
||||
|
||||
// Should show addons
|
||||
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show plans
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all types when "All" is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// First filter to plans only
|
||||
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||
await user.selectOptions(typeFilter, 'plan');
|
||||
|
||||
// Then select "All"
|
||||
await user.selectOptions(typeFilter, 'all');
|
||||
|
||||
// Should show both
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Filtering', () => {
|
||||
it('filters to show only active items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||
await user.selectOptions(statusFilter, 'active');
|
||||
|
||||
// Should show active items
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show inactive items
|
||||
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to show only inactive items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||
await user.selectOptions(statusFilter, 'inactive');
|
||||
|
||||
// Should show inactive items
|
||||
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deprecated Add-on')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show active items
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visibility Filtering', () => {
|
||||
it('filters to show only public items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
|
||||
await user.selectOptions(visibilityFilter, 'public');
|
||||
|
||||
// Should show public items
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show hidden items
|
||||
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to show only hidden items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
|
||||
await user.selectOptions(visibilityFilter, 'hidden');
|
||||
|
||||
// Should show hidden items
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show public items
|
||||
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy Filtering', () => {
|
||||
it('filters to show only current (non-legacy) items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
|
||||
await user.selectOptions(legacyFilter, 'current');
|
||||
|
||||
// Should show current items
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show legacy items
|
||||
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to show only legacy items', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
|
||||
await user.selectOptions(legacyFilter, 'legacy');
|
||||
|
||||
// Should show legacy items
|
||||
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show current items
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('filters items by name when searching', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
await user.type(searchInput, 'starter');
|
||||
|
||||
// Should show Starter plan
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show other items
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Professional')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters items by code when searching', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
await user.type(searchInput, 'sms_pack');
|
||||
|
||||
// Should show SMS Credits Pack
|
||||
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show other items
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results message when search has no matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
await user.type(searchInput, 'nonexistent');
|
||||
|
||||
expect(screen.getByText(/no items found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search is case-insensitive', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
await user.type(searchInput, 'STARTER');
|
||||
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection', () => {
|
||||
it('calls onSelect when an item is clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} onSelect={onSelect} />);
|
||||
|
||||
const starterItem = screen.getByText('Starter').closest('button');
|
||||
await user.click(starterItem!);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 2,
|
||||
code: 'starter',
|
||||
}));
|
||||
});
|
||||
|
||||
it('highlights the selected item', () => {
|
||||
render(<CatalogListPanel {...defaultProps} selectedId={2} />);
|
||||
|
||||
// The selected item should have a different style
|
||||
const starterItem = screen.getByText('Starter').closest('button');
|
||||
expect(starterItem).toHaveClass('bg-blue-50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Actions', () => {
|
||||
it('calls onCreatePlan when "Create Plan" is clicked', async () => {
|
||||
const onCreatePlan = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} onCreatePlan={onCreatePlan} />);
|
||||
|
||||
const createPlanButton = screen.getByRole('button', { name: /create plan/i });
|
||||
await user.click(createPlanButton);
|
||||
|
||||
expect(onCreatePlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCreateAddon when "Create Add-on" is clicked', async () => {
|
||||
const onCreateAddon = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} onCreateAddon={onCreateAddon} />);
|
||||
|
||||
const createAddonButton = screen.getByRole('button', { name: /create add-on/i });
|
||||
await user.click(createAddonButton);
|
||||
|
||||
expect(onCreateAddon).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Filters', () => {
|
||||
it('combines type and status filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Filter to inactive plans only
|
||||
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||
|
||||
await user.selectOptions(typeFilter, 'plan');
|
||||
await user.selectOptions(statusFilter, 'inactive');
|
||||
|
||||
// Should only show inactive plans
|
||||
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show active plans or addons
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('combines search with filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CatalogListPanel {...defaultProps} />);
|
||||
|
||||
// Filter to plans and search for "pro"
|
||||
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||
await user.selectOptions(typeFilter, 'plan');
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
await user.type(searchInput, 'pro');
|
||||
|
||||
// Should show Professional and Pro (Legacy)
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show other items
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Tests for FeaturePicker Component
|
||||
*
|
||||
* TDD: These tests define the expected behavior of the FeaturePicker component.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FeaturePicker, FeaturePickerProps } from '../FeaturePicker';
|
||||
import { FEATURE_CATALOG, BOOLEAN_FEATURES, INTEGER_FEATURES } from '../../featureCatalog';
|
||||
import type { PlanFeatureWrite } from '../../../hooks/useBillingAdmin';
|
||||
|
||||
// Mock features from API (similar to what useFeatures() returns)
|
||||
const mockApiFeatures = [
|
||||
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'Allow SMS', feature_type: 'boolean' as const },
|
||||
{ id: 2, code: 'email_enabled', name: 'Email Enabled', description: 'Allow email', feature_type: 'boolean' as const },
|
||||
{ id: 3, code: 'max_users', name: 'Maximum Users', description: 'Max users limit', feature_type: 'integer' as const },
|
||||
{ id: 4, code: 'max_resources', name: 'Maximum Resources', description: 'Max resources', feature_type: 'integer' as const },
|
||||
{ id: 5, code: 'custom_feature', name: 'Custom Feature', description: 'Not in catalog', feature_type: 'boolean' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wrapper component that manages state for controlled FeaturePicker
|
||||
*/
|
||||
const StatefulFeaturePicker: React.FC<
|
||||
Omit<FeaturePickerProps, 'selectedFeatures' | 'onChange'> & {
|
||||
initialSelectedFeatures?: PlanFeatureWrite[];
|
||||
onChangeCapture?: (features: PlanFeatureWrite[]) => void;
|
||||
}
|
||||
> = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => {
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<PlanFeatureWrite[]>(initialSelectedFeatures);
|
||||
|
||||
const handleChange = (features: PlanFeatureWrite[]) => {
|
||||
setSelectedFeatures(features);
|
||||
onChangeCapture?.(features);
|
||||
};
|
||||
|
||||
return (
|
||||
<FeaturePicker
|
||||
{...props}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('FeaturePicker', () => {
|
||||
const defaultProps = {
|
||||
features: mockApiFeatures,
|
||||
selectedFeatures: [],
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders boolean features in Capabilities section', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Capabilities')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders integer features in Limits & Quotas section', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Limits & Quotas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Maximum Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Maximum Resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selected features as checked', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows integer values for selected integer features', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 50 },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const input = screen.getByDisplayValue('50');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Selection', () => {
|
||||
it('calls onChange when a boolean feature is selected', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls onChange when a boolean feature is deselected', async () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('calls onChange when an integer feature is selected with default value 0', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /maximum users/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls onChange when integer value is updated', async () => {
|
||||
const initialSelectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||
];
|
||||
const onChangeCapture = vi.fn();
|
||||
|
||||
render(
|
||||
<StatefulFeaturePicker
|
||||
features={mockApiFeatures}
|
||||
initialSelectedFeatures={initialSelectedFeatures}
|
||||
onChangeCapture={onChangeCapture}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '50');
|
||||
|
||||
// Should have been called multiple times as user types
|
||||
expect(onChangeCapture).toHaveBeenCalled();
|
||||
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||
expect(lastCall).toContainEqual({ feature_code: 'max_users', bool_value: null, int_value: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canonical Catalog Validation', () => {
|
||||
it('shows warning badge for features not in canonical catalog', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// custom_feature is not in the canonical catalog
|
||||
const customFeatureRow = screen.getByText('Custom Feature').closest('label');
|
||||
expect(customFeatureRow).toBeInTheDocument();
|
||||
|
||||
// Should show a warning indicator
|
||||
const warningIndicator = within(customFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||
expect(warningIndicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning for canonical features', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// sms_enabled is in the canonical catalog
|
||||
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
|
||||
expect(smsFeatureRow).toBeInTheDocument();
|
||||
|
||||
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||
expect(warningIndicator).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('filters features when search term is entered', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'sms');
|
||||
|
||||
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Email Enabled')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Maximum Users')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results message when search has no matches', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'nonexistent');
|
||||
|
||||
expect(screen.getByText(/no features found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears search when clear button is clicked', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'sms');
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear search/i });
|
||||
await userEvent.click(clearButton);
|
||||
|
||||
expect(searchInput).toHaveValue('');
|
||||
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payload Shape', () => {
|
||||
it('produces correct payload shape for boolean features', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /email enabled/i }));
|
||||
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
|
||||
// Verify payload shape matches PlanFeatureWrite interface
|
||||
expect(lastCall).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
feature_code: expect.any(String),
|
||||
bool_value: expect.any(Boolean),
|
||||
int_value: null,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('produces correct payload shape for integer features', async () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 25 },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('25');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '100');
|
||||
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
|
||||
expect(lastCall).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
feature_code: 'max_users',
|
||||
bool_value: null,
|
||||
int_value: expect.any(Number),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('produces correct payload shape for mixed selection', async () => {
|
||||
const onChangeCapture = vi.fn();
|
||||
render(
|
||||
<StatefulFeaturePicker
|
||||
features={mockApiFeatures}
|
||||
onChangeCapture={onChangeCapture}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select a boolean feature
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||
// Select an integer feature
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /maximum users/i }));
|
||||
|
||||
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||
|
||||
expect(lastCall).toHaveLength(2);
|
||||
expect(lastCall).toContainEqual({
|
||||
feature_code: 'sms_enabled',
|
||||
bool_value: true,
|
||||
int_value: null,
|
||||
});
|
||||
expect(lastCall).toContainEqual({
|
||||
feature_code: 'max_users',
|
||||
bool_value: null,
|
||||
int_value: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible labels for all checkboxes', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// Each feature should have an accessible checkbox
|
||||
mockApiFeatures.forEach((feature) => {
|
||||
const checkbox = screen.getByRole('checkbox', { name: new RegExp(feature.name, 'i') });
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('integer input has accessible label', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
expect(input).toHaveAttribute('aria-label');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Tests for PlanEditorWizard Component Validation
|
||||
*
|
||||
* TDD: These tests define the expected validation behavior.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { PlanEditorWizard } from '../PlanEditorWizard';
|
||||
|
||||
// Create a fresh query client for each test
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Wrapper with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../../hooks/useBillingAdmin', () => ({
|
||||
useFeatures: () => ({
|
||||
data: [
|
||||
{ id: 1, code: 'sms_enabled', name: 'SMS', feature_type: 'boolean' },
|
||||
{ id: 2, code: 'max_users', name: 'Max Users', feature_type: 'integer' },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useAddOnProducts: () => ({
|
||||
data: [{ id: 1, code: 'addon1', name: 'Add-on 1', is_active: true }],
|
||||
isLoading: false,
|
||||
}),
|
||||
useCreatePlan: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1, code: 'test' }),
|
||||
isPending: false,
|
||||
}),
|
||||
useCreatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1, version: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePlan: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useForceUpdatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
|
||||
isPending: false,
|
||||
}),
|
||||
isForceUpdateConfirmRequired: (response: unknown) =>
|
||||
response !== null &&
|
||||
typeof response === 'object' &&
|
||||
'requires_confirm' in response &&
|
||||
(response as { requires_confirm: boolean }).requires_confirm === true,
|
||||
}));
|
||||
|
||||
// Mock useCurrentUser from useAuth
|
||||
vi.mock('../../../hooks/useAuth', () => ({
|
||||
useCurrentUser: () => ({
|
||||
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PlanEditorWizard', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'create' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basics Step Validation', () => {
|
||||
it('requires plan name to proceed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Plan code is entered but name is empty
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
await user.type(codeInput, 'test_plan');
|
||||
|
||||
// Try to click Next
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('requires plan code to proceed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Name is entered but code is empty
|
||||
const nameInput = screen.getByLabelText(/display name/i);
|
||||
await user.type(nameInput, 'Test Plan');
|
||||
|
||||
// Next button should be disabled
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('allows proceeding when code and name are provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Enter both code and name
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
const nameInput = screen.getByLabelText(/display name/i);
|
||||
|
||||
await user.type(codeInput, 'test_plan');
|
||||
await user.type(nameInput, 'Test Plan');
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('sanitizes plan code to lowercase with no spaces', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
await user.type(codeInput, 'My Test Plan');
|
||||
|
||||
// Should be sanitized
|
||||
expect(codeInput).toHaveValue('mytestplan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing Step Validation', () => {
|
||||
const goToPricingStep = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Go to pricing step
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it('shows pricing step inputs', async () => {
|
||||
await goToPricingStep();
|
||||
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/yearly price/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not allow negative monthly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||
await user.clear(monthlyInput);
|
||||
await user.type(monthlyInput, '-50');
|
||||
|
||||
// Should show validation error or prevent input
|
||||
// The input type="number" with min="0" should prevent negative values
|
||||
expect(monthlyInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('does not allow negative yearly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||
await user.clear(yearlyInput);
|
||||
await user.type(yearlyInput, '-100');
|
||||
|
||||
// Should have min attribute set
|
||||
expect(yearlyInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('displays derived monthly equivalent for yearly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||
await user.clear(yearlyInput);
|
||||
await user.type(yearlyInput, '120');
|
||||
|
||||
// Should show the monthly equivalent ($10/mo)
|
||||
expect(screen.getByText(/\$10.*mo/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Fees Validation', () => {
|
||||
const goToPricingStep = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics and navigate
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it('validates fee percent is between 0 and 100', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
|
||||
// Should have min and max attributes
|
||||
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||
expect(feePercentInput).toHaveAttribute('max', '100');
|
||||
});
|
||||
|
||||
it('does not allow fee percent over 100', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
await user.clear(feePercentInput);
|
||||
await user.type(feePercentInput, '150');
|
||||
|
||||
// Should show validation warning
|
||||
expect(screen.getByText(/must be between 0 and 100/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not allow negative fee percent', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
|
||||
// Input has min="0" attribute to prevent negative values
|
||||
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('shows transaction fee example calculation', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
// Should show example like "On a $100 transaction: $4.40 fee"
|
||||
expect(screen.getByText(/on a.*transaction/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wizard Navigation', () => {
|
||||
it('shows all wizard steps', () => {
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Should show step indicators (they have aria-label)
|
||||
expect(screen.getByRole('button', { name: /basics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /pricing/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /features/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /display/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates back from pricing to basics', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics and go to pricing
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
// Should be on pricing step
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
|
||||
// Click back
|
||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||
|
||||
// Should be back on basics
|
||||
expect(screen.getByLabelText(/plan code/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows clicking step indicators to navigate', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Click on Pricing step indicator
|
||||
const pricingStep = screen.getByRole('button', { name: /pricing/i });
|
||||
await user.click(pricingStep);
|
||||
|
||||
// Should navigate to pricing
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live Summary Panel', () => {
|
||||
it('shows plan name in summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText(/display name/i), 'My Amazing Plan');
|
||||
|
||||
// Summary should show the plan name
|
||||
expect(screen.getByText('My Amazing Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows price in summary after entering pricing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
// Enter price
|
||||
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||
await user.clear(monthlyInput);
|
||||
await user.type(monthlyInput, '29');
|
||||
|
||||
// Summary should show the price
|
||||
expect(screen.getByText(/\$29/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selected features count in summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Navigate to features step
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // to pricing
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // to features
|
||||
|
||||
// Select a feature
|
||||
const smsCheckbox = screen.getByRole('checkbox', { name: /sms/i });
|
||||
await user.click(smsCheckbox);
|
||||
|
||||
// Summary should show feature count
|
||||
expect(screen.getByText(/1 feature/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Version Confirmation', () => {
|
||||
it('shows grandfathering warning when editing version with subscribers', async () => {
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Should show warning about subscribers and grandfathering
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscriber/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/grandfathering/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Create New Version" confirmation for version with subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step and try to save
|
||||
// The save button should mention "Create New Version"
|
||||
const saveButton = screen.queryByRole('button', { name: /create new version/i });
|
||||
expect(saveButton || screen.getByText(/new version/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('calls onClose after successful creation', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlanEditorWizard {...defaultProps} onClose={onClose} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Fill all required fields
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Navigate through wizard
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // pricing
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // features
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // display
|
||||
|
||||
// Submit
|
||||
const createButton = screen.getByRole('button', { name: /create plan/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Force Update (Superuser)', () => {
|
||||
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Should show "Update Without Versioning" button
|
||||
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Click the force update button
|
||||
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||
|
||||
// Should show confirmation dialog with warning
|
||||
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
|
||||
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can cancel force update confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Click the force update button
|
||||
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||
|
||||
// Click Cancel
|
||||
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
|
||||
|
||||
// Confirmation dialog should be hidden, back to normal footer
|
||||
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 0, // No subscribers
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step
|
||||
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||
|
||||
// Should NOT show "Update Without Versioning" button
|
||||
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
445
frontend/src/billing/featureCatalog.ts
Normal file
445
frontend/src/billing/featureCatalog.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Canonical Feature Catalog
|
||||
*
|
||||
* This file defines the canonical list of features available in the SmoothSchedule
|
||||
* billing system. Features are organized by type (boolean vs integer) and include
|
||||
* human-readable labels and descriptions.
|
||||
*
|
||||
* IMPORTANT: When adding new feature codes, add them here first to maintain a
|
||||
* single source of truth. The FeaturePicker component uses this catalog to
|
||||
* provide autocomplete and validation.
|
||||
*
|
||||
* Feature Types:
|
||||
* - Boolean: On/off capabilities (e.g., sms_enabled, api_access)
|
||||
* - Integer: Limit/quota features (e.g., max_users, max_resources)
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { FEATURE_CATALOG, getFeatureInfo, isCanonicalFeature } from '../billing/featureCatalog';
|
||||
*
|
||||
* // Get info about a feature
|
||||
* const info = getFeatureInfo('max_users');
|
||||
* // { code: 'max_users', name: 'Maximum Users', type: 'integer', ... }
|
||||
*
|
||||
* // Check if a feature is in the canonical catalog
|
||||
* const isCanonical = isCanonicalFeature('custom_feature'); // false
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type FeatureType = 'boolean' | 'integer';
|
||||
|
||||
export interface FeatureCatalogEntry {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: FeatureType;
|
||||
category: FeatureCategory;
|
||||
}
|
||||
|
||||
export type FeatureCategory =
|
||||
| 'communication'
|
||||
| 'limits'
|
||||
| 'access'
|
||||
| 'branding'
|
||||
| 'support'
|
||||
| 'integrations'
|
||||
| 'security'
|
||||
| 'scheduling';
|
||||
|
||||
// =============================================================================
|
||||
// Boolean Features (Capabilities)
|
||||
// =============================================================================
|
||||
|
||||
export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
||||
// Communication
|
||||
{
|
||||
code: 'sms_enabled',
|
||||
name: 'SMS Messaging',
|
||||
description: 'Send SMS notifications and reminders to customers',
|
||||
type: 'boolean',
|
||||
category: 'communication',
|
||||
},
|
||||
{
|
||||
code: 'masked_calling_enabled',
|
||||
name: 'Masked Calling',
|
||||
description: 'Make calls with masked caller ID for privacy',
|
||||
type: 'boolean',
|
||||
category: 'communication',
|
||||
},
|
||||
{
|
||||
code: 'proxy_number_enabled',
|
||||
name: 'Proxy Phone Numbers',
|
||||
description: 'Use proxy phone numbers for customer communication',
|
||||
type: 'boolean',
|
||||
category: 'communication',
|
||||
},
|
||||
|
||||
// Payments & Commerce
|
||||
{
|
||||
code: 'can_accept_payments',
|
||||
name: 'Accept Payments',
|
||||
description: 'Accept online payments via Stripe Connect',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_use_pos',
|
||||
name: 'Point of Sale',
|
||||
description: 'Use Point of Sale (POS) system',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
|
||||
// Scheduling & Booking
|
||||
{
|
||||
code: 'recurring_appointments',
|
||||
name: 'Recurring Appointments',
|
||||
description: 'Schedule recurring appointments',
|
||||
type: 'boolean',
|
||||
category: 'scheduling',
|
||||
},
|
||||
{
|
||||
code: 'group_bookings',
|
||||
name: 'Group Bookings',
|
||||
description: 'Allow multiple customers per appointment',
|
||||
type: 'boolean',
|
||||
category: 'scheduling',
|
||||
},
|
||||
{
|
||||
code: 'waitlist',
|
||||
name: 'Waitlist',
|
||||
description: 'Enable waitlist for fully booked slots',
|
||||
type: 'boolean',
|
||||
category: 'scheduling',
|
||||
},
|
||||
{
|
||||
code: 'can_add_video_conferencing',
|
||||
name: 'Video Conferencing',
|
||||
description: 'Add video conferencing to events',
|
||||
type: 'boolean',
|
||||
category: 'scheduling',
|
||||
},
|
||||
|
||||
// Access & Features
|
||||
{
|
||||
code: 'api_access',
|
||||
name: 'API Access',
|
||||
description: 'Access the public API for integrations',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_use_analytics',
|
||||
name: 'Analytics Dashboard',
|
||||
description: 'Access business analytics and reporting',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_use_tasks',
|
||||
name: 'Automated Tasks',
|
||||
description: 'Create and run automated task workflows',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_use_contracts',
|
||||
name: 'Contracts & E-Signatures',
|
||||
description: 'Create and manage e-signature contracts',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'customer_portal',
|
||||
name: 'Customer Portal',
|
||||
description: 'Branded self-service portal for customers',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'custom_fields',
|
||||
name: 'Custom Fields',
|
||||
description: 'Create custom data fields for resources and events',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_export_data',
|
||||
name: 'Data Export',
|
||||
description: 'Export data (appointments, customers, etc.)',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
{
|
||||
code: 'can_use_mobile_app',
|
||||
name: 'Mobile App',
|
||||
description: 'Access the mobile app for field employees',
|
||||
type: 'boolean',
|
||||
category: 'access',
|
||||
},
|
||||
|
||||
// Integrations
|
||||
{
|
||||
code: 'calendar_sync',
|
||||
name: 'Calendar Sync',
|
||||
description: 'Sync with Google Calendar, Outlook, etc.',
|
||||
type: 'boolean',
|
||||
category: 'integrations',
|
||||
},
|
||||
{
|
||||
code: 'webhooks_enabled',
|
||||
name: 'Webhooks',
|
||||
description: 'Send webhook notifications for events',
|
||||
type: 'boolean',
|
||||
category: 'integrations',
|
||||
},
|
||||
{
|
||||
code: 'can_use_plugins',
|
||||
name: 'Plugin Integrations',
|
||||
description: 'Use third-party plugin integrations',
|
||||
type: 'boolean',
|
||||
category: 'integrations',
|
||||
},
|
||||
{
|
||||
code: 'can_create_plugins',
|
||||
name: 'Create Plugins',
|
||||
description: 'Create custom plugins for automation',
|
||||
type: 'boolean',
|
||||
category: 'integrations',
|
||||
},
|
||||
{
|
||||
code: 'can_manage_oauth_credentials',
|
||||
name: 'Manage OAuth',
|
||||
description: 'Manage your own OAuth credentials',
|
||||
type: 'boolean',
|
||||
category: 'integrations',
|
||||
},
|
||||
|
||||
// Branding
|
||||
{
|
||||
code: 'custom_branding',
|
||||
name: 'Custom Branding',
|
||||
description: 'Customize branding colors, logo, and styling',
|
||||
type: 'boolean',
|
||||
category: 'branding',
|
||||
},
|
||||
{
|
||||
code: 'white_label',
|
||||
name: 'White Label',
|
||||
description: 'Remove SmoothSchedule branding completely',
|
||||
type: 'boolean',
|
||||
category: 'branding',
|
||||
},
|
||||
{
|
||||
code: 'can_use_custom_domain',
|
||||
name: 'Custom Domain',
|
||||
description: 'Configure a custom domain for your booking page',
|
||||
type: 'boolean',
|
||||
category: 'branding',
|
||||
},
|
||||
|
||||
// Support
|
||||
{
|
||||
code: 'priority_support',
|
||||
name: 'Priority Support',
|
||||
description: 'Get priority customer support response',
|
||||
type: 'boolean',
|
||||
category: 'support',
|
||||
},
|
||||
|
||||
// Security & Compliance
|
||||
{
|
||||
code: 'can_require_2fa',
|
||||
name: 'Require 2FA',
|
||||
description: 'Require two-factor authentication for users',
|
||||
type: 'boolean',
|
||||
category: 'security',
|
||||
},
|
||||
{
|
||||
code: 'sso_enabled',
|
||||
name: 'Single Sign-On (SSO)',
|
||||
description: 'Enable SSO authentication for team members',
|
||||
type: 'boolean',
|
||||
category: 'security',
|
||||
},
|
||||
{
|
||||
code: 'can_delete_data',
|
||||
name: 'Delete Data',
|
||||
description: 'Permanently delete data',
|
||||
type: 'boolean',
|
||||
category: 'security',
|
||||
},
|
||||
{
|
||||
code: 'can_download_logs',
|
||||
name: 'Download Logs',
|
||||
description: 'Download system logs',
|
||||
type: 'boolean',
|
||||
category: 'security',
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Integer Features (Limits & Quotas)
|
||||
// =============================================================================
|
||||
|
||||
export const INTEGER_FEATURES: FeatureCatalogEntry[] = [
|
||||
// User/Resource Limits
|
||||
{
|
||||
code: 'max_users',
|
||||
name: 'Maximum Team Members',
|
||||
description: 'Maximum number of team member accounts (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_resources',
|
||||
name: 'Maximum Resources',
|
||||
description: 'Maximum number of resources (staff, rooms, equipment)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_locations',
|
||||
name: 'Location Limit',
|
||||
description: 'Maximum number of business locations (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_services',
|
||||
name: 'Maximum Services',
|
||||
description: 'Maximum number of service types (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_customers',
|
||||
name: 'Customer Limit',
|
||||
description: 'Maximum number of customer records (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_event_types',
|
||||
name: 'Max Event Types',
|
||||
description: 'Maximum number of event types',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
|
||||
// Usage Limits
|
||||
{
|
||||
code: 'max_appointments_per_month',
|
||||
name: 'Monthly Appointment Limit',
|
||||
description: 'Maximum appointments per month (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_automated_tasks',
|
||||
name: 'Automated Task Limit',
|
||||
description: 'Maximum number of automated tasks (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_email_templates',
|
||||
name: 'Email Template Limit',
|
||||
description: 'Maximum number of custom email templates (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_calendars_connected',
|
||||
name: 'Max Calendars',
|
||||
description: 'Maximum number of external calendars connected',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
|
||||
// Technical Limits
|
||||
{
|
||||
code: 'storage_gb',
|
||||
name: 'Storage (GB)',
|
||||
description: 'File storage limit in gigabytes (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
{
|
||||
code: 'max_api_requests_per_day',
|
||||
name: 'Daily API Request Limit',
|
||||
description: 'Maximum API requests per day (0 = unlimited)',
|
||||
type: 'integer',
|
||||
category: 'limits',
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Combined Catalog
|
||||
// =============================================================================
|
||||
|
||||
export const FEATURE_CATALOG: FeatureCatalogEntry[] = [
|
||||
...BOOLEAN_FEATURES,
|
||||
...INTEGER_FEATURES,
|
||||
];
|
||||
|
||||
// Create a lookup map for quick access
|
||||
const featureMap = new Map<string, FeatureCatalogEntry>(
|
||||
FEATURE_CATALOG.map((f) => [f.code, f])
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get feature information by code
|
||||
*/
|
||||
export const getFeatureInfo = (code: string): FeatureCatalogEntry | undefined => {
|
||||
return featureMap.get(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a feature code is in the canonical catalog
|
||||
*/
|
||||
export const isCanonicalFeature = (code: string): boolean => {
|
||||
return featureMap.has(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all features by type
|
||||
*/
|
||||
export const getFeaturesByType = (type: FeatureType): FeatureCatalogEntry[] => {
|
||||
return FEATURE_CATALOG.filter((f) => f.type === type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all features by category
|
||||
*/
|
||||
export const getFeaturesByCategory = (category: FeatureCategory): FeatureCatalogEntry[] => {
|
||||
return FEATURE_CATALOG.filter((f) => f.category === category);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all unique categories
|
||||
*/
|
||||
export const getAllCategories = (): FeatureCategory[] => {
|
||||
return [...new Set(FEATURE_CATALOG.map((f) => f.category))];
|
||||
};
|
||||
|
||||
/**
|
||||
* Format category name for display
|
||||
*/
|
||||
export const formatCategoryName = (category: FeatureCategory): string => {
|
||||
const names: Record<FeatureCategory, string> = {
|
||||
communication: 'Communication',
|
||||
limits: 'Limits & Quotas',
|
||||
access: 'Access & Features',
|
||||
branding: 'Branding & Customization',
|
||||
support: 'Support',
|
||||
integrations: 'Integrations',
|
||||
security: 'Security & Compliance',
|
||||
scheduling: 'Scheduling & Booking',
|
||||
};
|
||||
return names[category];
|
||||
};
|
||||
27
frontend/src/billing/index.ts
Normal file
27
frontend/src/billing/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Billing Module
|
||||
*
|
||||
* Components and utilities for the billing management system.
|
||||
*
|
||||
* Component Structure:
|
||||
* - CatalogListPanel: Left sidebar with search/filter and item list
|
||||
* - PlanDetailPanel: Main panel showing selected plan/addon details
|
||||
* - PlanEditorWizard: Multi-step wizard for creating/editing plans
|
||||
* - FeaturePicker: Feature selection UI for plans
|
||||
*
|
||||
* To add new feature codes:
|
||||
* 1. Add the feature to featureCatalog.ts in BOOLEAN_FEATURES or INTEGER_FEATURES
|
||||
* 2. Run migrations in backend if needed
|
||||
* 3. Features in the catalog get validation and display benefits
|
||||
*/
|
||||
|
||||
// Feature Catalog
|
||||
export * from './featureCatalog';
|
||||
|
||||
// Components
|
||||
export { FeaturePicker } from './components/FeaturePicker';
|
||||
export { PlanEditorWizard } from './components/PlanEditorWizard';
|
||||
export { CatalogListPanel } from './components/CatalogListPanel';
|
||||
export { PlanDetailPanel } from './components/PlanDetailPanel';
|
||||
export { AddOnEditorModal } from './components/AddOnEditorModal';
|
||||
export type { CatalogItem } from './components/CatalogListPanel';
|
||||
@@ -1,8 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from '../api/client';
|
||||
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
SCHEDULE_PRESETS,
|
||||
TRIGGER_OPTIONS,
|
||||
OFFSET_PRESETS,
|
||||
getScheduleDescription,
|
||||
getEventTimingDescription,
|
||||
} from '../constants/schedulePresets';
|
||||
import { ErrorMessage } from './ui';
|
||||
|
||||
interface PluginInstallation {
|
||||
id: string;
|
||||
@@ -14,11 +22,11 @@ interface PluginInstallation {
|
||||
version: string;
|
||||
author_name: string;
|
||||
logo_url?: string;
|
||||
template_variables: Record<string, any>;
|
||||
template_variables: Record<string, unknown>;
|
||||
scheduled_task?: number;
|
||||
scheduled_task_name?: string;
|
||||
installed_at: string;
|
||||
config_values: Record<string, any>;
|
||||
config_values: Record<string, unknown>;
|
||||
has_update: boolean;
|
||||
}
|
||||
|
||||
@@ -28,65 +36,6 @@ interface CreateTaskModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Schedule presets for visual selection
|
||||
interface SchedulePreset {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'INTERVAL' | 'CRON';
|
||||
interval_minutes?: number;
|
||||
cron_expression?: string;
|
||||
}
|
||||
|
||||
const SCHEDULE_PRESETS: SchedulePreset[] = [
|
||||
// Interval-based
|
||||
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
|
||||
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
|
||||
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
|
||||
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
|
||||
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
|
||||
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
|
||||
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
|
||||
// Cron-based (specific times)
|
||||
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
|
||||
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
|
||||
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
|
||||
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
|
||||
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
|
||||
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
|
||||
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
|
||||
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
|
||||
];
|
||||
|
||||
// Event trigger options (same as EventAutomations component)
|
||||
interface TriggerOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface OffsetPreset {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TRIGGER_OPTIONS: TriggerOption[] = [
|
||||
{ value: 'before_start', label: 'Before Start' },
|
||||
{ value: 'at_start', label: 'At Start' },
|
||||
{ value: 'after_start', label: 'After Start' },
|
||||
{ value: 'after_end', label: 'After End' },
|
||||
{ value: 'on_complete', label: 'When Completed' },
|
||||
{ value: 'on_cancel', label: 'When Canceled' },
|
||||
];
|
||||
|
||||
const OFFSET_PRESETS: OffsetPreset[] = [
|
||||
{ value: 0, label: 'Immediately' },
|
||||
{ value: 5, label: '5 min' },
|
||||
{ value: 10, label: '10 min' },
|
||||
{ value: 15, label: '15 min' },
|
||||
{ value: 30, label: '30 min' },
|
||||
{ value: 60, label: '1 hour' },
|
||||
];
|
||||
|
||||
// Task type: scheduled or event-based
|
||||
type TaskType = 'scheduled' | 'event';
|
||||
|
||||
@@ -154,41 +103,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const getScheduleDescription = () => {
|
||||
if (scheduleMode === 'onetime') {
|
||||
if (runAtDate && runAtTime) {
|
||||
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
|
||||
}
|
||||
return 'Select date and time';
|
||||
}
|
||||
if (scheduleMode === 'advanced') {
|
||||
return `Custom: ${customCron}`;
|
||||
}
|
||||
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
|
||||
return preset?.description || 'Select a schedule';
|
||||
};
|
||||
// Use shared helper functions from constants
|
||||
const scheduleDescriptionText = getScheduleDescription(
|
||||
scheduleMode,
|
||||
selectedPreset,
|
||||
runAtDate,
|
||||
runAtTime,
|
||||
customCron
|
||||
);
|
||||
|
||||
const getEventTimingDescription = () => {
|
||||
const trigger = TRIGGER_OPTIONS.find(t => t.value === selectedTrigger);
|
||||
if (!trigger) return 'Select timing';
|
||||
|
||||
if (selectedTrigger === 'on_complete') return 'When event is completed';
|
||||
if (selectedTrigger === 'on_cancel') return 'When event is canceled';
|
||||
|
||||
if (selectedOffset === 0) {
|
||||
if (selectedTrigger === 'before_start') return 'At event start';
|
||||
if (selectedTrigger === 'at_start') return 'At event start';
|
||||
if (selectedTrigger === 'after_start') return 'At event start';
|
||||
if (selectedTrigger === 'after_end') return 'At event end';
|
||||
}
|
||||
|
||||
const offsetLabel = OFFSET_PRESETS.find(o => o.value === selectedOffset)?.label || `${selectedOffset} min`;
|
||||
if (selectedTrigger === 'before_start') return `${offsetLabel} before event starts`;
|
||||
if (selectedTrigger === 'at_start' || selectedTrigger === 'after_start') return `${offsetLabel} after event starts`;
|
||||
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
|
||||
|
||||
return trigger.label;
|
||||
};
|
||||
const eventTimingDescriptionText = getEventTimingDescription(selectedTrigger, selectedOffset);
|
||||
|
||||
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
|
||||
|
||||
@@ -543,7 +467,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-800 dark:text-green-200">
|
||||
<strong>Schedule:</strong> {getScheduleDescription()}
|
||||
<strong>Schedule:</strong> {scheduleDescriptionText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -657,7 +581,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm text-purple-800 dark:text-purple-200">
|
||||
<strong>Runs:</strong> {getEventTimingDescription()}
|
||||
<strong>Runs:</strong> {eventTimingDescriptionText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -665,11 +589,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorMessage message={error} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
247
frontend/src/components/FeatureGate.tsx
Normal file
247
frontend/src/components/FeatureGate.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* FeatureGate Component
|
||||
*
|
||||
* Conditionally renders children based on entitlement checks.
|
||||
* Used to show/hide features based on the business's subscription plan.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useEntitlements } from '../hooks/useEntitlements';
|
||||
|
||||
// ============================================================================
|
||||
// FeatureGate - For boolean feature checks
|
||||
// ============================================================================
|
||||
|
||||
interface FeatureGateProps {
|
||||
/**
|
||||
* Single feature code to check
|
||||
*/
|
||||
feature?: string;
|
||||
|
||||
/**
|
||||
* Multiple feature codes to check
|
||||
*/
|
||||
features?: string[];
|
||||
|
||||
/**
|
||||
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
|
||||
* Default: true (all required)
|
||||
*/
|
||||
requireAll?: boolean;
|
||||
|
||||
/**
|
||||
* Content to render when feature(s) are enabled
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content to render when feature(s) are NOT enabled
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content to render while entitlements are loading
|
||||
*/
|
||||
loadingFallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally render content based on feature entitlements.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Single feature check
|
||||
* <FeatureGate feature="can_use_sms_reminders">
|
||||
* <SMSSettings />
|
||||
* </FeatureGate>
|
||||
*
|
||||
* // With fallback
|
||||
* <FeatureGate
|
||||
* feature="can_use_sms_reminders"
|
||||
* fallback={<UpgradePrompt feature="SMS Reminders" />}
|
||||
* >
|
||||
* <SMSSettings />
|
||||
* </FeatureGate>
|
||||
*
|
||||
* // Multiple features (all required)
|
||||
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
|
||||
* <TaskScheduler />
|
||||
* </FeatureGate>
|
||||
*
|
||||
* // Multiple features (any one)
|
||||
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
|
||||
* <NotificationSettings />
|
||||
* </FeatureGate>
|
||||
* ```
|
||||
*/
|
||||
export const FeatureGate: React.FC<FeatureGateProps> = ({
|
||||
feature,
|
||||
features,
|
||||
requireAll = true,
|
||||
children,
|
||||
fallback = null,
|
||||
loadingFallback = null,
|
||||
}) => {
|
||||
const { hasFeature, isLoading } = useEntitlements();
|
||||
|
||||
// Show loading state if provided
|
||||
if (isLoading) {
|
||||
return <>{loadingFallback}</>;
|
||||
}
|
||||
|
||||
// Determine which features to check
|
||||
const featuresToCheck = features ?? (feature ? [feature] : []);
|
||||
|
||||
if (featuresToCheck.length === 0) {
|
||||
// No features specified, render children
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check features
|
||||
const hasAccess = requireAll
|
||||
? featuresToCheck.every((f) => hasFeature(f))
|
||||
: featuresToCheck.some((f) => hasFeature(f));
|
||||
|
||||
if (hasAccess) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LimitGate - For integer limit checks
|
||||
// ============================================================================
|
||||
|
||||
interface LimitGateProps {
|
||||
/**
|
||||
* The limit feature code to check (e.g., 'max_users')
|
||||
*/
|
||||
limit: string;
|
||||
|
||||
/**
|
||||
* Current usage count
|
||||
*/
|
||||
currentUsage: number;
|
||||
|
||||
/**
|
||||
* Content to render when under the limit
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content to render when at or over the limit
|
||||
*/
|
||||
fallback?: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Content to render while entitlements are loading
|
||||
*/
|
||||
loadingFallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally render content based on usage limits.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LimitGate
|
||||
* limit="max_users"
|
||||
* currentUsage={users.length}
|
||||
* fallback={<UpgradePrompt message="You've reached your user limit" />}
|
||||
* >
|
||||
* <AddUserButton />
|
||||
* </LimitGate>
|
||||
* ```
|
||||
*/
|
||||
export const LimitGate: React.FC<LimitGateProps> = ({
|
||||
limit,
|
||||
currentUsage,
|
||||
children,
|
||||
fallback = null,
|
||||
loadingFallback = null,
|
||||
}) => {
|
||||
const { getLimit, isLoading } = useEntitlements();
|
||||
|
||||
// Show loading state if provided
|
||||
if (isLoading) {
|
||||
return <>{loadingFallback}</>;
|
||||
}
|
||||
|
||||
const maxLimit = getLimit(limit);
|
||||
|
||||
// If limit is null, treat as unlimited
|
||||
if (maxLimit === null) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Check if under limit
|
||||
if (currentUsage < maxLimit) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <>{fallback}</>;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Components
|
||||
// ============================================================================
|
||||
|
||||
interface UpgradePromptProps {
|
||||
/**
|
||||
* Feature name to display
|
||||
*/
|
||||
feature?: string;
|
||||
|
||||
/**
|
||||
* Custom message
|
||||
*/
|
||||
message?: string;
|
||||
|
||||
/**
|
||||
* Upgrade URL (defaults to /settings/billing)
|
||||
*/
|
||||
upgradeUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default upgrade prompt component.
|
||||
* Can be used as a fallback in FeatureGate/LimitGate.
|
||||
*/
|
||||
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
|
||||
feature,
|
||||
message,
|
||||
upgradeUrl = '/settings/billing',
|
||||
}) => {
|
||||
const displayMessage =
|
||||
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
|
||||
</div>
|
||||
<a
|
||||
href={upgradeUrl}
|
||||
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
|
||||
>
|
||||
View upgrade options →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureGate;
|
||||
134
frontend/src/components/LocationSelector.tsx
Normal file
134
frontend/src/components/LocationSelector.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* LocationSelector Component
|
||||
*
|
||||
* A reusable dropdown for selecting a business location.
|
||||
* Hidden when only one location exists.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useLocations } from '../hooks/useLocations';
|
||||
import { FormSelect, SelectOption } from './ui/FormSelect';
|
||||
import { Location } from '../types';
|
||||
|
||||
interface LocationSelectorProps {
|
||||
/** Currently selected location ID */
|
||||
value?: number | null;
|
||||
/** Callback when location is selected */
|
||||
onChange: (locationId: number | null) => void;
|
||||
/** Label for the selector */
|
||||
label?: string;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
/** Hint text */
|
||||
hint?: string;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Whether the field is required */
|
||||
required?: boolean;
|
||||
/** Whether to include inactive locations */
|
||||
includeInactive?: boolean;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
/** Force show even with single location (for admin purposes) */
|
||||
forceShow?: boolean;
|
||||
/** Container class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSelector - Dropdown for selecting a business location
|
||||
*
|
||||
* Automatically hides when:
|
||||
* - Only one active location exists (unless forceShow is true)
|
||||
* - Locations are still loading
|
||||
*
|
||||
* The component auto-selects the only location when there's just one.
|
||||
*/
|
||||
export const LocationSelector: React.FC<LocationSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Location',
|
||||
error,
|
||||
hint,
|
||||
placeholder = 'Select a location',
|
||||
required = false,
|
||||
includeInactive = false,
|
||||
disabled = false,
|
||||
forceShow = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { data: locations, isLoading, isError } = useLocations({ includeInactive });
|
||||
|
||||
// Don't render if loading or error
|
||||
if (isLoading || isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only active locations if not including inactive
|
||||
const availableLocations = locations ?? [];
|
||||
|
||||
// Hide if only one location (unless forceShow)
|
||||
if (availableLocations.length <= 1 && !forceShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build options from locations
|
||||
const options: SelectOption<string>[] = availableLocations.map((loc: Location) => ({
|
||||
value: String(loc.id),
|
||||
label: loc.is_primary
|
||||
? `${loc.name} (Primary)`
|
||||
: loc.is_active
|
||||
? loc.name
|
||||
: `${loc.name} (Inactive)`,
|
||||
disabled: !loc.is_active && !includeInactive,
|
||||
}));
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedValue = e.target.value;
|
||||
onChange(selectedValue ? Number(selectedValue) : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
label={label}
|
||||
value={value ? String(value) : ''}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
error={error}
|
||||
hint={hint}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
containerClassName={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to determine if location selector should be shown
|
||||
*/
|
||||
export const useShouldShowLocationSelector = (includeInactive = false): boolean => {
|
||||
const { data: locations, isLoading } = useLocations({ includeInactive });
|
||||
|
||||
if (isLoading) return false;
|
||||
return (locations?.length ?? 0) > 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to auto-select location when only one exists
|
||||
*/
|
||||
export const useAutoSelectLocation = (
|
||||
currentValue: number | null | undefined,
|
||||
onChange: (locationId: number | null) => void
|
||||
) => {
|
||||
const { data: locations } = useLocations();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Auto-select if only one location and no value selected
|
||||
if (locations?.length === 1 && !currentValue) {
|
||||
onChange(locations[0].id);
|
||||
}
|
||||
}, [locations, currentValue, onChange]);
|
||||
};
|
||||
|
||||
export default LocationSelector;
|
||||
@@ -59,7 +59,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
// Handle time-off request notifications - navigate to time blocks page
|
||||
// Includes both new requests and modified requests that need re-approval
|
||||
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
|
||||
navigate('/time-blocks');
|
||||
navigate('/dashboard/time-blocks');
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
@@ -224,7 +224,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/notifications');
|
||||
navigate('/dashboard/notifications');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
|
||||
@@ -235,7 +235,7 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/settings/billing')}
|
||||
onClick={() => navigate('/dashboard/settings/billing')}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={16} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
|
||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
|
||||
@@ -75,6 +75,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
||||
<Shield size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||
</Link>
|
||||
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
|
||||
<CreditCard size={18} className="shrink-0" />
|
||||
{!isCollapsed && <span>Billing</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>}
|
||||
|
||||
@@ -244,7 +244,7 @@ const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismi
|
||||
{t('quota.modal.dismissButton', 'Remind Me Later')}
|
||||
</button>
|
||||
<Link
|
||||
to="/settings/quota"
|
||||
to="/dashboard/settings/quota"
|
||||
onClick={handleDismiss}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
|
||||
@@ -78,7 +78,7 @@ const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDis
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/settings/quota"
|
||||
to="/dashboard/settings/quota"
|
||||
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
isCritical || isUrgent
|
||||
? 'bg-white/20 hover:bg-white/30 text-white'
|
||||
|
||||
@@ -217,8 +217,9 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const drSmith = screen.getByText('Dr. Smith').closest('div');
|
||||
const confRoom = screen.getByText('Conference Room A').closest('div');
|
||||
// The height style is on the resource row container (3 levels up from the text)
|
||||
const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]');
|
||||
const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]');
|
||||
|
||||
expect(drSmith).toHaveStyle({ height: '100px' });
|
||||
expect(confRoom).toHaveStyle({ height: '120px' });
|
||||
@@ -420,7 +421,8 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const appointment = screen.getByText('John Doe').closest('div');
|
||||
// Navigate up to the draggable container which has the svg
|
||||
const appointment = screen.getByText('John Doe').closest('.cursor-grab');
|
||||
const svg = appointment?.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
@@ -544,8 +546,9 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const appointmentCard = screen.getByText('John Doe').closest('div');
|
||||
expect(appointmentCard).toHaveClass('cursor-grab');
|
||||
// Use the specific class selector since .closest('div') returns the inner div
|
||||
const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab');
|
||||
expect(appointmentCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply active cursor-grabbing class to draggable items', () => {
|
||||
@@ -558,8 +561,9 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const appointmentCard = screen.getByText('John Doe').closest('div');
|
||||
expect(appointmentCard).toHaveClass('active:cursor-grabbing');
|
||||
// Verify the draggable container has the active:cursor-grabbing class
|
||||
const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]');
|
||||
expect(appointmentCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render pending items with orange left border', () => {
|
||||
@@ -572,8 +576,9 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const appointmentCard = screen.getByText('John Doe').closest('div');
|
||||
expect(appointmentCard).toHaveClass('border-l-orange-400');
|
||||
// Use the specific class selector
|
||||
const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400');
|
||||
expect(appointmentCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply shadow on hover for draggable items', () => {
|
||||
@@ -586,8 +591,9 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const appointmentCard = screen.getByText('John Doe').closest('div');
|
||||
expect(appointmentCard).toHaveClass('hover:shadow-md');
|
||||
// Use the specific class selector
|
||||
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
|
||||
expect(appointmentCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -649,7 +655,8 @@ describe('Sidebar', () => {
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const header = screen.getByText('Resources').parentElement;
|
||||
// The height style is on the header div itself
|
||||
const header = screen.getByText('Resources').closest('[style*="height"]');
|
||||
expect(header).toHaveStyle({ height: '48px' });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
Plug,
|
||||
FileSignature,
|
||||
CalendarOff,
|
||||
LayoutTemplate,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||
import UnfinishedBadge from './ui/UnfinishedBadge';
|
||||
import {
|
||||
SidebarSection,
|
||||
SidebarItem,
|
||||
@@ -106,7 +109,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{/* Core Features - Always visible */}
|
||||
<SidebarSection isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/"
|
||||
to="/dashboard"
|
||||
icon={LayoutDashboard}
|
||||
label={t('nav.dashboard')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -114,7 +117,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
/>
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
to="/dashboard/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -122,16 +125,17 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/tasks"
|
||||
to="/dashboard/tasks"
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{isStaff && (
|
||||
<SidebarItem
|
||||
to="/my-schedule"
|
||||
to="/dashboard/my-schedule"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.mySchedule', 'My Schedule')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -139,7 +143,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
<SidebarItem
|
||||
to="/my-availability"
|
||||
to="/dashboard/my-availability"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.myAvailability', 'My Availability')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -151,19 +155,27 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{canViewManagementPages && (
|
||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/customers"
|
||||
to="/dashboard/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/services"
|
||||
to="/dashboard/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/resources"
|
||||
to="/dashboard/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -171,25 +183,34 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
to="/dashboard/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
{canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/contracts"
|
||||
to="/dashboard/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/time-blocks"
|
||||
to="/dashboard/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/locations"
|
||||
icon={MapPin}
|
||||
label={t('nav.locations', 'Locations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('multi_location')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SidebarSection>
|
||||
@@ -200,7 +221,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
||||
{canSendMessages && (
|
||||
<SidebarItem
|
||||
to="/messages"
|
||||
to="/dashboard/messages"
|
||||
icon={MessageSquare}
|
||||
label={t('nav.messages')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -208,7 +229,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
{canViewTickets && (
|
||||
<SidebarItem
|
||||
to="/tickets"
|
||||
to="/dashboard/tickets"
|
||||
icon={Ticket}
|
||||
label={t('nav.tickets')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -221,7 +242,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{canViewAdminPages && (
|
||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/payments"
|
||||
to="/dashboard/payments"
|
||||
icon={CreditCard}
|
||||
label={t('nav.payments')}
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -234,11 +255,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{canViewAdminPages && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/plugins/my-plugins"
|
||||
to="/dashboard/plugins/my-plugins"
|
||||
icon={Plug}
|
||||
label={t('nav.plugins', 'Plugins')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -249,14 +271,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<SidebarSection isCollapsed={isCollapsed}>
|
||||
{canViewSettings && (
|
||||
<SidebarItem
|
||||
to="/settings"
|
||||
to="/dashboard/settings"
|
||||
icon={Settings}
|
||||
label={t('nav.businessSettings')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/help"
|
||||
to="/dashboard/help"
|
||||
icon={HelpCircle}
|
||||
label={t('nav.helpDocs', 'Help & Docs')}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -28,7 +28,7 @@ const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
|
||||
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
|
||||
|
||||
const handleUpgrade = () => {
|
||||
navigate('/upgrade');
|
||||
navigate('/dashboard/upgrade');
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ const BannerPrompt: React.FC<{ feature: FeatureKey; showDescription: boolean }>
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
to="/dashboard/settings/billing"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Crown className="w-4 h-4" />
|
||||
@@ -97,7 +97,7 @@ const OverlayPrompt: React.FC<{
|
||||
{FEATURE_DESCRIPTIONS[feature]}
|
||||
</p>
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
to="/dashboard/settings/billing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Crown className="w-5 h-5" />
|
||||
|
||||
166
frontend/src/components/__tests__/ApiTokensSection.test.tsx
Normal file
166
frontend/src/components/__tests__/ApiTokensSection.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import ApiTokensSection from '../ApiTokensSection';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the hooks
|
||||
const mockTokens = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Token',
|
||||
key_prefix: 'abc123',
|
||||
scopes: ['read:appointments', 'write:appointments'],
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_used_at: '2024-01-02T00:00:00Z',
|
||||
expires_at: null,
|
||||
created_by: { full_name: 'John Doe', username: 'john' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Revoked Token',
|
||||
key_prefix: 'xyz789',
|
||||
scopes: ['read:resources'],
|
||||
is_active: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
created_by: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseApiTokens = vi.fn();
|
||||
const mockUseCreateApiToken = vi.fn();
|
||||
const mockUseRevokeApiToken = vi.fn();
|
||||
const mockUseUpdateApiToken = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/useApiTokens', () => ({
|
||||
useApiTokens: () => mockUseApiTokens(),
|
||||
useCreateApiToken: () => mockUseCreateApiToken(),
|
||||
useRevokeApiToken: () => mockUseRevokeApiToken(),
|
||||
useUpdateApiToken: () => mockUseUpdateApiToken(),
|
||||
API_SCOPES: [
|
||||
{ value: 'read:appointments', label: 'Read Appointments', description: 'View appointments' },
|
||||
{ value: 'write:appointments', label: 'Write Appointments', description: 'Create/edit appointments' },
|
||||
{ value: 'read:resources', label: 'Read Resources', description: 'View resources' },
|
||||
],
|
||||
SCOPE_PRESETS: {
|
||||
read_only: { label: 'Read Only', description: 'View data only', scopes: ['read:appointments', 'read:resources'] },
|
||||
read_write: { label: 'Read & Write', description: 'Full access', scopes: ['read:appointments', 'write:appointments', 'read:resources'] },
|
||||
custom: { label: 'Custom', description: 'Select individual permissions', scopes: [] },
|
||||
},
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ApiTokensSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseCreateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||
mockUseRevokeApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||
mockUseUpdateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: false, error: new Error('Failed') });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/Failed to load API tokens/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no tokens', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('No API tokens yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens list', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Test Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revoked Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section title', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders New Token button', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('New Token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders API Docs link', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('API Docs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens new token modal when button clicked', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByText('New Token'));
|
||||
// Modal title should appear
|
||||
expect(screen.getByRole('heading', { name: 'Create API Token' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows active tokens count', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/Active Tokens \(1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows revoked tokens count', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/Revoked Tokens \(1\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows token key prefix', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/abc123••••••••/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows revoked badge for inactive tokens', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Revoked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description text', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/Create and manage API tokens/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders create button in empty state', () => {
|
||||
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Create API Token')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,429 +1,114 @@
|
||||
/**
|
||||
* Unit tests for ConfirmationModal component
|
||||
*
|
||||
* Tests all modal functionality including:
|
||||
* - Rendering with different props (title, message, variants)
|
||||
* - User interactions (confirm, cancel, close button)
|
||||
* - Custom button labels
|
||||
* - Loading states
|
||||
* - Modal visibility (isOpen true/false)
|
||||
* - Different modal variants (info, warning, danger, success)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
|
||||
// Setup i18n for tests
|
||||
beforeEach(() => {
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
common: {
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Test wrapper with i18n provider
|
||||
const renderWithI18n = (component: React.ReactElement) => {
|
||||
return render(<I18nextProvider i18n={i18n}>{component}</I18nextProvider>);
|
||||
};
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ConfirmationModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
title: 'Test Title',
|
||||
message: 'Test message',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with title and message', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(
|
||||
<ConfirmationModal {...defaultProps} isOpen={false} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
|
||||
});
|
||||
it('renders title when open', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with React node as message', () => {
|
||||
const messageNode = (
|
||||
<div>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
</div>
|
||||
);
|
||||
it('renders message when open', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} message={messageNode} />);
|
||||
it('renders message as ReactNode', () => {
|
||||
render(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
message={<span data-testid="custom-message">Custom content</span>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('First paragraph')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second paragraph')).toBeInTheDocument();
|
||||
});
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} isOpen={false} />
|
||||
);
|
||||
it('calls onClose when cancel button is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('common.cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('common.confirm'));
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render default confirm and cancel buttons', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
it('uses custom confirm text', () => {
|
||||
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
|
||||
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
it('uses custom cancel text', () => {
|
||||
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
|
||||
expect(screen.getByText('No, keep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom button labels', () => {
|
||||
renderWithI18n(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
confirmText="Yes, delete it"
|
||||
cancelText="No, keep it"
|
||||
/>
|
||||
);
|
||||
it('renders info variant', () => {
|
||||
render(<ConfirmationModal {...defaultProps} variant="info" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
|
||||
});
|
||||
it('renders warning variant', () => {
|
||||
render(<ConfirmationModal {...defaultProps} variant="warning" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render close button in header', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
it('renders danger variant', () => {
|
||||
render(<ConfirmationModal {...defaultProps} variant="danger" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close button is an SVG icon, so we find it by its parent button
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const closeButton = closeButtons.find((button) =>
|
||||
button.querySelector('svg') && button !== screen.getByRole('button', { name: /confirm/i })
|
||||
);
|
||||
it('renders success variant', () => {
|
||||
render(<ConfirmationModal {...defaultProps} variant="success" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
it('disables buttons when loading', () => {
|
||||
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onConfirm when confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn();
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when cancel button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
// Find the close button (X icon in header)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const closeButton = buttons.find((button) =>
|
||||
button.querySelector('svg') && !button.textContent?.includes('Confirm')
|
||||
);
|
||||
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not call onConfirm multiple times on multiple clicks', () => {
|
||||
const onConfirm = vi.fn();
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(confirmButton);
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner when isLoading is true', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
const spinner = confirmButton.querySelector('svg.animate-spin');
|
||||
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable confirm button when loading', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable cancel button when loading', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable close button when loading', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const closeButton = buttons.find((button) =>
|
||||
button.querySelector('svg') && !button.textContent?.includes('Confirm')
|
||||
);
|
||||
|
||||
expect(closeButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not call onConfirm when button is disabled due to loading', () => {
|
||||
const onConfirm = vi.fn();
|
||||
renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
// Button is disabled, so onClick should not fire
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Variants', () => {
|
||||
it('should render info variant by default', () => {
|
||||
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
// Info variant has blue styling
|
||||
const iconContainer = container.querySelector('.bg-blue-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render info variant with correct styling', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} variant="info" />
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.bg-blue-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toHaveClass('bg-blue-600');
|
||||
});
|
||||
|
||||
it('should render warning variant with correct styling', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} variant="warning" />
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.bg-amber-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toHaveClass('bg-amber-600');
|
||||
});
|
||||
|
||||
it('should render danger variant with correct styling', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} variant="danger" />
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.bg-red-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toHaveClass('bg-red-600');
|
||||
});
|
||||
|
||||
it('should render success variant with correct styling', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} variant="success" />
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.bg-green-100');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toHaveClass('bg-green-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper button roles', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2); // At least confirm and cancel
|
||||
});
|
||||
|
||||
it('should have backdrop overlay', () => {
|
||||
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/50');
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have modal content container', () => {
|
||||
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
const modal = container.querySelector('.bg-white.dark\\:bg-gray-800.rounded-xl');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty title', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} title="" />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty message', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} message="" />);
|
||||
|
||||
const title = screen.getByText('Confirm Action');
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very long title', () => {
|
||||
const longTitle = 'A'.repeat(200);
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} title={longTitle} />);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very long message', () => {
|
||||
const longMessage = 'B'.repeat(500);
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} message={longMessage} />);
|
||||
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid open/close state changes', () => {
|
||||
const { rerender } = renderWithI18n(<ConfirmationModal {...defaultProps} isOpen={true} />);
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ConfirmationModal {...defaultProps} isOpen={false} />
|
||||
</I18nextProvider>
|
||||
);
|
||||
expect(screen.queryByText('Confirm Action')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ConfirmationModal {...defaultProps} isOpen={true} />
|
||||
</I18nextProvider>
|
||||
);
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete User Flows', () => {
|
||||
it('should support complete confirmation flow', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
title="Delete Item"
|
||||
message="Are you sure you want to delete this item?"
|
||||
variant="danger"
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
/>
|
||||
);
|
||||
|
||||
// User sees the modal
|
||||
expect(screen.getByText('Delete Item')).toBeInTheDocument();
|
||||
expect(screen.getByText('Are you sure you want to delete this item?')).toBeInTheDocument();
|
||||
|
||||
// User clicks confirm
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support complete cancellation flow', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
variant="warning"
|
||||
/>
|
||||
);
|
||||
|
||||
// User sees the modal
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
|
||||
// User clicks cancel
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support loading state during async operation', () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { rerender } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={false} />
|
||||
);
|
||||
|
||||
// Initial state - buttons enabled
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
|
||||
// User clicks confirm
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Parent component sets loading state
|
||||
rerender(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
// Buttons now disabled during async operation
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
|
||||
});
|
||||
it('shows spinner when loading', () => {
|
||||
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,752 +1,83 @@
|
||||
/**
|
||||
* Unit tests for EmailTemplateSelector component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Rendering with templates list
|
||||
* - Template selection and onChange callback
|
||||
* - Selected template display (active state)
|
||||
* - Empty templates array handling
|
||||
* - Loading states
|
||||
* - Disabled state
|
||||
* - Category filtering
|
||||
* - Template info display
|
||||
* - Edit link functionality
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React, { type ReactNode } from 'react';
|
||||
import EmailTemplateSelector from '../EmailTemplateSelector';
|
||||
import apiClient from '../../api/client';
|
||||
import { EmailTemplate } from '../../types';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback: string) => fallback,
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data factories
|
||||
const createMockEmailTemplate = (overrides?: Partial<EmailTemplate>): EmailTemplate => ({
|
||||
id: '1',
|
||||
name: 'Test Template',
|
||||
description: 'Test description',
|
||||
subject: 'Test Subject',
|
||||
htmlContent: '<p>Test content</p>',
|
||||
textContent: 'Test content',
|
||||
scope: 'BUSINESS',
|
||||
isDefault: false,
|
||||
category: 'APPOINTMENT',
|
||||
...overrides,
|
||||
});
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('EmailTemplateSelector', () => {
|
||||
let queryClient: QueryClient;
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
it('renders select element', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
it('shows placeholder text after loading', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
placeholder="Select a template"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish and placeholder to appear
|
||||
await screen.findByText('Select a template');
|
||||
});
|
||||
|
||||
describe('Rendering with templates', () => {
|
||||
it('should render with templates list', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Welcome Email' }),
|
||||
createMockEmailTemplate({ id: '2', name: 'Confirmation Email', category: 'CONFIRMATION' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
const options = Array.from(select.options);
|
||||
|
||||
expect(options).toHaveLength(3); // placeholder + 2 templates
|
||||
expect(options[1]).toHaveTextContent('Welcome Email (APPOINTMENT)');
|
||||
expect(options[2]).toHaveTextContent('Confirmation Email (CONFIRMATION)');
|
||||
});
|
||||
|
||||
it('should render templates without category suffix for OTHER category', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Custom Email', category: 'OTHER' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
const options = Array.from(select.options);
|
||||
|
||||
expect(options[1]).toHaveTextContent('Custom Email');
|
||||
expect(options[1]).not.toHaveTextContent('(OTHER)');
|
||||
});
|
||||
|
||||
it('should convert numeric IDs to strings', async () => {
|
||||
const mockData = [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Numeric ID Template',
|
||||
description: 'Test',
|
||||
category: 'REMINDER',
|
||||
scope: 'BUSINESS',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockData });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options[1].value).toBe('123');
|
||||
});
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
describe('Template selection', () => {
|
||||
it('should select template on click', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
createMockEmailTemplate({ id: '2', name: 'Template 2' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: '2' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('2');
|
||||
});
|
||||
|
||||
it('should call onChange with undefined when selecting empty option', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: '' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should handle numeric value prop', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={1} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.value).toBe('1');
|
||||
});
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
className="custom-class"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
describe('Selected template display', () => {
|
||||
it('should show selected template as active', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({
|
||||
id: '1',
|
||||
name: 'Selected Template',
|
||||
description: 'This template is selected',
|
||||
}),
|
||||
createMockEmailTemplate({ id: '2', name: 'Other Template' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.value).toBe('1');
|
||||
});
|
||||
|
||||
it('should display selected template info with description', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({
|
||||
id: '1',
|
||||
name: 'Template Name',
|
||||
description: 'Template description text',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Template description text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display template name when description is empty', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({
|
||||
id: '1',
|
||||
name: 'No Description Template',
|
||||
description: '',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No Description Template')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display edit link for selected template', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Editable Template' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editLink = screen.getByRole('link', { name: /edit/i });
|
||||
expect(editLink).toBeInTheDocument();
|
||||
expect(editLink).toHaveAttribute('href', '#/email-templates');
|
||||
expect(editLink).toHaveAttribute('target', '_blank');
|
||||
expect(editLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display template info when no template is selected', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editLink = screen.queryByRole('link', { name: /edit/i });
|
||||
expect(editLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty templates array', () => {
|
||||
it('should handle empty templates array', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no email templates yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display create link when templates array is empty', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const createLink = screen.getByRole('link', { name: /create your first template/i });
|
||||
expect(createLink).toBeInTheDocument();
|
||||
expect(createLink).toHaveAttribute('href', '#/email-templates');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render select with only placeholder option when empty', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options).toHaveLength(1); // only placeholder
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading states', () => {
|
||||
it('should show loading text in placeholder when loading', async () => {
|
||||
vi.mocked(apiClient.get).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves to keep loading state
|
||||
);
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options[0]).toHaveTextContent('Loading...');
|
||||
});
|
||||
|
||||
it('should disable select when loading', async () => {
|
||||
vi.mocked(apiClient.get).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should not show empty state while loading', () => {
|
||||
vi.mocked(apiClient.get).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
const emptyMessage = screen.queryByText(/no email templates yet/i);
|
||||
expect(emptyMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled state', () => {
|
||||
it('should disable select when disabled prop is true', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply disabled attribute when disabled prop is true', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeDisabled();
|
||||
});
|
||||
|
||||
// Verify the select element has disabled attribute
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Category filtering', () => {
|
||||
it('should fetch templates with category filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch templates without category filter when not provided', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?');
|
||||
});
|
||||
});
|
||||
|
||||
it('should refetch when category changes', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
rerender(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="CONFIRMATION" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=CONFIRMATION');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props and customization', () => {
|
||||
it('should use custom placeholder when provided', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
placeholder="Choose an email template"
|
||||
/>,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options[0]).toHaveTextContent('Choose an email template');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default placeholder when not provided', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||||
expect(select.options[0]).toHaveTextContent('Select a template...');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
className="custom-class"
|
||||
/>,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByRole('combobox').parentElement?.parentElement;
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
it('should work without className prop', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should display Mail icon', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const container = screen.getByRole('combobox').parentElement;
|
||||
const svg = container?.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display ExternalLink icon for selected template', async () => {
|
||||
const mockTemplates = [
|
||||
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
data: mockTemplates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
})),
|
||||
});
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const editLink = screen.getByRole('link', { name: /edit/i });
|
||||
const svg = editLink.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('API error handling', () => {
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const error = new Error('API Error');
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
|
||||
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
// Component should still render the select
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
it('shows empty state message when no templates', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish
|
||||
await screen.findByText('No email templates yet.');
|
||||
});
|
||||
});
|
||||
|
||||
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Tests for FeatureGate component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { FeatureGate, LimitGate } from '../FeatureGate';
|
||||
import * as useEntitlementsModule from '../../hooks/useEntitlements';
|
||||
|
||||
// Mock the useEntitlements hook
|
||||
vi.mock('../../hooks/useEntitlements', () => ({
|
||||
useEntitlements: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('FeatureGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when feature is enabled', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { can_use_sms_reminders: true },
|
||||
isLoading: false,
|
||||
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate feature="can_use_sms_reminders">
|
||||
<div>SMS Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render children when feature is disabled', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { can_use_sms_reminders: false },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate feature="can_use_sms_reminders">
|
||||
<div>SMS Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders fallback when feature is disabled', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { can_use_sms_reminders: false },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate
|
||||
feature="can_use_sms_reminders"
|
||||
fallback={<div>Upgrade to access SMS</div>}
|
||||
>
|
||||
<div>SMS Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing while loading', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: {},
|
||||
isLoading: true,
|
||||
hasFeature: () => false,
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate feature="can_use_sms_reminders">
|
||||
<div>SMS Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading component when provided and loading', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: {},
|
||||
isLoading: true,
|
||||
hasFeature: () => false,
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate
|
||||
feature="can_use_sms_reminders"
|
||||
loadingFallback={<div>Loading...</div>}
|
||||
>
|
||||
<div>SMS Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks multiple features with requireAll=true', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: {
|
||||
can_use_sms_reminders: true,
|
||||
can_use_mobile_app: false,
|
||||
},
|
||||
isLoading: false,
|
||||
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate
|
||||
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||
requireAll={true}
|
||||
>
|
||||
<div>Multi Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
// Should not render because mobile_app is disabled
|
||||
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks multiple features with requireAll=false (any)', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: {
|
||||
can_use_sms_reminders: true,
|
||||
can_use_mobile_app: false,
|
||||
},
|
||||
isLoading: false,
|
||||
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<FeatureGate
|
||||
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||
requireAll={false}
|
||||
>
|
||||
<div>Multi Feature Content</div>
|
||||
</FeatureGate>
|
||||
);
|
||||
|
||||
// Should render because at least one (sms) is enabled
|
||||
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LimitGate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders children when under limit', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { max_users: 10 },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<LimitGate limit="max_users" currentUsage={5}>
|
||||
<div>Under Limit Content</div>
|
||||
</LimitGate>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render children when at limit', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { max_users: 10 },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<LimitGate limit="max_users" currentUsage={10}>
|
||||
<div>Under Limit Content</div>
|
||||
</LimitGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render children when over limit', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { max_users: 10 },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<LimitGate limit="max_users" currentUsage={15}>
|
||||
<div>Under Limit Content</div>
|
||||
</LimitGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders fallback when over limit', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: { max_users: 10 },
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<LimitGate
|
||||
limit="max_users"
|
||||
currentUsage={15}
|
||||
fallback={<div>Upgrade for more users</div>}
|
||||
>
|
||||
<div>Under Limit Content</div>
|
||||
</LimitGate>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when limit is null (unlimited)', () => {
|
||||
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||
entitlements: {},
|
||||
isLoading: false,
|
||||
hasFeature: () => false,
|
||||
getLimit: () => null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<LimitGate limit="max_users" currentUsage={1000}>
|
||||
<div>Unlimited Content</div>
|
||||
</LimitGate>
|
||||
);
|
||||
|
||||
// When limit is null, treat as unlimited
|
||||
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import FloatingHelpButton from '../FloatingHelpButton';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('FloatingHelpButton', () => {
|
||||
const renderWithRouter = (initialPath: string) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<FloatingHelpButton />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders help link on dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to correct help page for dashboard', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/dashboard');
|
||||
});
|
||||
|
||||
it('links to correct help page for scheduler', () => {
|
||||
renderWithRouter('/scheduler');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/scheduler');
|
||||
});
|
||||
|
||||
it('links to correct help page for services', () => {
|
||||
renderWithRouter('/services');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/services');
|
||||
});
|
||||
|
||||
it('links to correct help page for resources', () => {
|
||||
renderWithRouter('/resources');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/resources');
|
||||
});
|
||||
|
||||
it('links to correct help page for settings', () => {
|
||||
renderWithRouter('/settings/general');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/settings/general');
|
||||
});
|
||||
|
||||
it('returns null on help pages', () => {
|
||||
const { container } = renderWithRouter('/help/dashboard');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('has aria-label', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('aria-label', 'Help');
|
||||
});
|
||||
|
||||
it('has title attribute', () => {
|
||||
renderWithRouter('/dashboard');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
|
||||
it('links to default help for unknown routes', () => {
|
||||
renderWithRouter('/unknown-route');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help');
|
||||
});
|
||||
|
||||
it('handles dynamic routes by matching prefix', () => {
|
||||
renderWithRouter('/customers/123');
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/customers');
|
||||
});
|
||||
});
|
||||
@@ -1,264 +1,57 @@
|
||||
/**
|
||||
* Unit tests for HelpButton component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - Link navigation
|
||||
* - Icon display
|
||||
* - Text display and responsive behavior
|
||||
* - Accessibility attributes
|
||||
* - Custom className prop
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import HelpButton from '../HelpButton';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback: string) => fallback,
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HelpButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<HelpButton {...props} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders help link', () => {
|
||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the button', () => {
|
||||
render(<HelpButton helpPath="/help/getting-started" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render as a Link component with correct href', () => {
|
||||
render(<HelpButton helpPath="/help/resources" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/resources');
|
||||
});
|
||||
|
||||
it('should render with different help paths', () => {
|
||||
const { rerender } = render(<HelpButton helpPath="/help/page1" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/page1');
|
||||
|
||||
rerender(<HelpButton helpPath="/help/page2" />);
|
||||
|
||||
link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/page2');
|
||||
});
|
||||
it('has correct href', () => {
|
||||
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/help/dashboard');
|
||||
});
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should display the HelpCircle icon', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
// Check for SVG icon (lucide-react renders as SVG)
|
||||
const svg = link.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
it('renders help text', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Text Display', () => {
|
||||
it('should display help text', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const text = screen.getByText('Help');
|
||||
expect(text).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply responsive class to hide text on small screens', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const text = screen.getByText('Help');
|
||||
expect(text).toHaveClass('hidden', 'sm:inline');
|
||||
});
|
||||
it('has title attribute', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have title attribute', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible as a link', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('should have accessible name from text content', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /help/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
it('applies custom className', () => {
|
||||
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply default classes', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('inline-flex');
|
||||
expect(link).toHaveClass('items-center');
|
||||
expect(link).toHaveClass('gap-1.5');
|
||||
expect(link).toHaveClass('px-3');
|
||||
expect(link).toHaveClass('py-1.5');
|
||||
expect(link).toHaveClass('text-sm');
|
||||
expect(link).toHaveClass('rounded-lg');
|
||||
expect(link).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('should apply color classes for light mode', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('text-gray-500');
|
||||
expect(link).toHaveClass('hover:text-brand-600');
|
||||
expect(link).toHaveClass('hover:bg-gray-100');
|
||||
});
|
||||
|
||||
it('should apply color classes for dark mode', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('dark:text-gray-400');
|
||||
expect(link).toHaveClass('dark:hover:text-brand-400');
|
||||
expect(link).toHaveClass('dark:hover:bg-gray-800');
|
||||
});
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
render(<HelpButton helpPath="/help" className="custom-class" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<HelpButton helpPath="/help" className="ml-auto" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('ml-auto');
|
||||
expect(link).toHaveClass('inline-flex');
|
||||
expect(link).toHaveClass('items-center');
|
||||
});
|
||||
|
||||
it('should work without custom className', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for help text', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// The mock returns the fallback value
|
||||
const text = screen.getByText('Help');
|
||||
expect(text).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for title attribute', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props together', () => {
|
||||
render(
|
||||
<HelpButton
|
||||
helpPath="/help/advanced"
|
||||
className="custom-styling"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/help/advanced');
|
||||
expect(link).toHaveAttribute('title', 'Help');
|
||||
expect(link).toHaveClass('custom-styling');
|
||||
expect(link).toHaveClass('inline-flex');
|
||||
|
||||
const icon = link.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
|
||||
const text = screen.getByText('Help');
|
||||
expect(text).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure with icon and text', () => {
|
||||
render(<HelpButton helpPath="/help" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
const svg = link.querySelector('svg');
|
||||
const span = link.querySelector('span');
|
||||
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(span).toBeInTheDocument();
|
||||
expect(span).toHaveTextContent('Help');
|
||||
});
|
||||
it('has default styles', () => {
|
||||
renderHelpButton({ helpPath: '/help/test' });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('inline-flex');
|
||||
expect(link).toHaveClass('items-center');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,560 +1,93 @@
|
||||
/**
|
||||
* Unit tests for LanguageSelector component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Rendering both dropdown and inline variants
|
||||
* - Current language display
|
||||
* - Dropdown open/close functionality
|
||||
* - Language selection and change
|
||||
* - Available languages display
|
||||
* - Flag display
|
||||
* - Click outside to close dropdown
|
||||
* - Accessibility attributes
|
||||
* - Responsive text hiding
|
||||
* - Custom className prop
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import LanguageSelector from '../LanguageSelector';
|
||||
|
||||
// Mock i18n
|
||||
const mockChangeLanguage = vi.fn();
|
||||
const mockCurrentLanguage = 'en';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
language: mockCurrentLanguage,
|
||||
changeLanguage: mockChangeLanguage,
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock i18n module with supported languages
|
||||
// Mock i18n module
|
||||
vi.mock('../../i18n', () => ({
|
||||
supportedLanguages: [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
],
|
||||
}));
|
||||
|
||||
describe('LanguageSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Dropdown Variant (Default)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the language selector button', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button', { expanded: false });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current language name on desktop', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const languageName = screen.getByText('English');
|
||||
expect(languageName).toBeInTheDocument();
|
||||
expect(languageName).toHaveClass('hidden', 'sm:inline');
|
||||
});
|
||||
|
||||
it('should display current language flag by default', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const flag = screen.getByText('🇺🇸');
|
||||
expect(flag).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display Globe icon', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const svg = button.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display ChevronDown icon', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
|
||||
expect(chevron).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display flag when showFlag is false', () => {
|
||||
render(<LanguageSelector showFlag={false} />);
|
||||
|
||||
const flag = screen.queryByText('🇺🇸');
|
||||
expect(flag).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show dropdown by default', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const dropdown = screen.queryByRole('listbox');
|
||||
expect(dropdown).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Open/Close', () => {
|
||||
it('should open dropdown when button clicked', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const dropdown = screen.getByRole('listbox');
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should close dropdown when button clicked again', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Open
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Close
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('should rotate chevron icon when dropdown is open', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
|
||||
|
||||
// Initially not rotated
|
||||
expect(chevron).not.toHaveClass('rotate-180');
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(button);
|
||||
expect(chevron).toHaveClass('rotate-180');
|
||||
});
|
||||
|
||||
it('should close dropdown when clicking outside', async () => {
|
||||
render(
|
||||
<div>
|
||||
<LanguageSelector />
|
||||
<button>Outside Button</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { expanded: false });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Click outside
|
||||
const outsideButton = screen.getByText('Outside Button');
|
||||
fireEvent.mouseDown(outsideButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not close dropdown when clicking inside dropdown', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const dropdown = screen.getByRole('listbox');
|
||||
fireEvent.mouseDown(dropdown);
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language Selection', () => {
|
||||
it('should display all available languages in dropdown', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getAllByText('English')).toHaveLength(2); // One in button, one in dropdown
|
||||
expect(screen.getByText('Español')).toBeInTheDocument();
|
||||
expect(screen.getByText('Français')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deutsch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display flags for all languages in dropdown', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getAllByText('🇺🇸')).toHaveLength(2); // One in button, one in dropdown
|
||||
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should mark current language with Check icon', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
const englishOption = options.find(opt => opt.textContent?.includes('English'));
|
||||
|
||||
expect(englishOption).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
// Check icon should be present
|
||||
const checkIcon = englishOption?.querySelector('svg.w-4.h-4');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should change language when option clicked', async () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const spanishOption = screen.getAllByRole('option').find(
|
||||
opt => opt.textContent?.includes('Español')
|
||||
);
|
||||
|
||||
fireEvent.click(spanishOption!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
|
||||
});
|
||||
});
|
||||
|
||||
it('should close dropdown after language selection', async () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const frenchOption = screen.getAllByRole('option').find(
|
||||
opt => opt.textContent?.includes('Français')
|
||||
);
|
||||
|
||||
fireEvent.click(frenchOption!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should highlight selected language with brand color', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
const englishOption = options.find(opt => opt.textContent?.includes('English'));
|
||||
|
||||
expect(englishOption).toHaveClass('bg-brand-50', 'dark:bg-brand-900/20');
|
||||
expect(englishOption).toHaveClass('text-brand-700', 'dark:text-brand-300');
|
||||
});
|
||||
|
||||
it('should not highlight non-selected languages with brand color', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
|
||||
|
||||
expect(spanishOption).toHaveClass('text-gray-700', 'dark:text-gray-300');
|
||||
expect(spanishOption).not.toHaveClass('bg-brand-50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper ARIA attributes on button', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(button).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
|
||||
it('should update aria-expanded when dropdown opens', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should have aria-label on listbox', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(listbox).toHaveAttribute('aria-label', 'Select language');
|
||||
});
|
||||
|
||||
it('should mark language options as selected correctly', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
const englishOption = options.find(opt => opt.textContent?.includes('English'));
|
||||
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
|
||||
|
||||
expect(englishOption).toHaveAttribute('aria-selected', 'true');
|
||||
expect(spanishOption).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply default classes to button', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('flex', 'items-center', 'gap-2');
|
||||
expect(button).toHaveClass('px-3', 'py-2');
|
||||
expect(button).toHaveClass('rounded-lg');
|
||||
expect(button).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
render(<LanguageSelector className="custom-class" />);
|
||||
|
||||
const container = screen.getByRole('button').parentElement;
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should apply dropdown animation classes', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
const dropdown = screen.getByRole('listbox').parentElement;
|
||||
expect(dropdown).toHaveClass('animate-in', 'fade-in', 'slide-in-from-top-2');
|
||||
});
|
||||
|
||||
it('should apply focus ring on button', () => {
|
||||
render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2', 'focus:ring-brand-500');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline Variant', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render inline variant when specified', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
// Should show buttons, not a dropdown
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBe(4); // One for each language
|
||||
});
|
||||
|
||||
it('should display all languages as separate buttons', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
expect(screen.getByText('Español')).toBeInTheDocument();
|
||||
expect(screen.getByText('Français')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deutsch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display flags in inline variant by default', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display flags when showFlag is false', () => {
|
||||
render(<LanguageSelector variant="inline" showFlag={false} />);
|
||||
|
||||
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('🇪🇸')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should highlight current language button', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const englishButton = screen.getByRole('button', { name: /English/i });
|
||||
expect(englishButton).toHaveClass('bg-brand-600', 'text-white');
|
||||
});
|
||||
|
||||
it('should not highlight non-selected language buttons', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const spanishButton = screen.getByRole('button', { name: /Español/i });
|
||||
expect(spanishButton).toHaveClass('bg-gray-100', 'text-gray-700');
|
||||
expect(spanishButton).not.toHaveClass('bg-brand-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language Selection', () => {
|
||||
it('should change language when button clicked', async () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const frenchButton = screen.getByRole('button', { name: /Français/i });
|
||||
fireEvent.click(frenchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('fr');
|
||||
});
|
||||
});
|
||||
|
||||
it('should change language for each available language', async () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const germanButton = screen.getByRole('button', { name: /Deutsch/i });
|
||||
fireEvent.click(germanButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('de');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply flex layout classes', () => {
|
||||
const { container } = render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('flex', 'flex-wrap', 'gap-2');
|
||||
});
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
const { container } = render(<LanguageSelector variant="inline" className="my-custom-class" />);
|
||||
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('my-custom-class');
|
||||
});
|
||||
|
||||
it('should apply button styling classes', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).toHaveClass('px-3', 'py-1.5', 'rounded-lg', 'text-sm', 'font-medium', 'transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply hover classes to non-selected buttons', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const spanishButton = screen.getByRole('button', { name: /Español/i });
|
||||
expect(spanishButton).toHaveClass('hover:bg-gray-200', 'dark:hover:bg-gray-600');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all dropdown props together', () => {
|
||||
render(
|
||||
<LanguageSelector
|
||||
variant="dropdown"
|
||||
showFlag={true}
|
||||
className="custom-class"
|
||||
/>
|
||||
);
|
||||
|
||||
describe('dropdown variant', () => {
|
||||
it('renders dropdown button', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
|
||||
|
||||
const container = button.parentElement;
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should render correctly with all inline props together', () => {
|
||||
const { container } = render(
|
||||
<LanguageSelector
|
||||
variant="inline"
|
||||
showFlag={true}
|
||||
className="inline-custom"
|
||||
/>
|
||||
);
|
||||
|
||||
const wrapper = container.firstChild;
|
||||
expect(wrapper).toHaveClass('inline-custom');
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBe(4);
|
||||
|
||||
it('shows current language flag by default', () => {
|
||||
render(<LanguageSelector />);
|
||||
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows current language name on larger screens', () => {
|
||||
render(<LanguageSelector />);
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain dropdown functionality across re-renders', () => {
|
||||
const { rerender } = render(<LanguageSelector />);
|
||||
|
||||
it('opens dropdown on click', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<LanguageSelector className="updated" />);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
it('shows all languages when open', () => {
|
||||
render(<LanguageSelector />);
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Español')).toBeInTheDocument();
|
||||
expect(screen.getByText('Français')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides flag when showFlag is false', () => {
|
||||
render(<LanguageSelector showFlag={false} />);
|
||||
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<LanguageSelector className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing language gracefully', () => {
|
||||
// The component should fall back to the first language if current language is not found
|
||||
render(<LanguageSelector />);
|
||||
|
||||
// Should still render without crashing
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cleanup event listener on unmount', () => {
|
||||
const { unmount } = render(<LanguageSelector />);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should not call changeLanguage when clicking current language', async () => {
|
||||
describe('inline variant', () => {
|
||||
it('renders all language buttons', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBe(3);
|
||||
});
|
||||
|
||||
const englishButton = screen.getByRole('button', { name: /English/i });
|
||||
fireEvent.click(englishButton);
|
||||
it('renders language names', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
expect(screen.getByText(/English/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Español/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Français/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
|
||||
});
|
||||
it('highlights current language', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
const englishButton = screen.getByText(/English/).closest('button');
|
||||
expect(englishButton).toHaveClass('bg-brand-600');
|
||||
});
|
||||
|
||||
// Even if clicking the current language, it still calls changeLanguage
|
||||
// This is expected behavior (idempotent)
|
||||
it('shows flags by default', () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
201
frontend/src/components/__tests__/LocationSelector.test.tsx
Normal file
201
frontend/src/components/__tests__/LocationSelector.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { LocationSelector, useShouldShowLocationSelector } from '../LocationSelector';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
// Mock the useLocations hook
|
||||
vi.mock('../../hooks/useLocations', () => ({
|
||||
useLocations: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useLocations } from '../../hooks/useLocations';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe('LocationSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when loading', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<LocationSelector value={null} onChange={onChange} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when there is only one location', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<LocationSelector value={null} onChange={onChange} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders selector when multiple locations exist', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
render(<LocationSelector value={null} onChange={onChange} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Location')).toBeInTheDocument();
|
||||
expect(screen.getByText('Main Office (Primary)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Branch Office')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows single location when forceShow is true', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
render(<LocationSelector value={null} onChange={onChange} forceShow />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Location')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when selection changes', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
render(<LocationSelector value={null} onChange={onChange} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const select = screen.getByLabelText('Location');
|
||||
fireEvent.change(select, { target: { value: '2' } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('marks inactive locations appropriately', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||
{ id: 2, name: 'Old Branch', is_active: false, is_primary: false },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
render(<LocationSelector value={null} onChange={onChange} includeInactive />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Old Branch (Inactive)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays custom label', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Location A', is_active: true, is_primary: false },
|
||||
{ id: 2, name: 'Location B', is_active: true, is_primary: false },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
} as any);
|
||||
|
||||
const onChange = vi.fn();
|
||||
render(<LocationSelector value={null} onChange={onChange} label="Select Store" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Select Store')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useShouldShowLocationSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns false when loading', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when only one location', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [{ id: 1, name: 'Main', is_active: true }],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when multiple locations exist', () => {
|
||||
vi.mocked(useLocations).mockReturnValue({
|
||||
data: [
|
||||
{ id: 1, name: 'Main', is_active: true },
|
||||
{ id: 2, name: 'Branch', is_active: true },
|
||||
],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,534 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import MasqueradeBanner from '../MasqueradeBanner';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
'platform.masquerade.masqueradingAs': 'Masquerading as',
|
||||
'platform.masquerade.loggedInAs': `Logged in as ${options?.name || ''}`,
|
||||
'platform.masquerade.returnTo': `Return to ${options?.name || ''}`,
|
||||
'platform.masquerade.stopMasquerading': 'Stop Masquerading',
|
||||
};
|
||||
return translations[key] || key;
|
||||
t: (key: string, options?: { name?: string }) => {
|
||||
if (options?.name) return `${key} ${options.name}`;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Eye: ({ size }: { size: number }) => <svg data-testid="eye-icon" width={size} height={size} />,
|
||||
XCircle: ({ size }: { size: number }) => <svg data-testid="xcircle-icon" width={size} height={size} />,
|
||||
}));
|
||||
|
||||
describe('MasqueradeBanner', () => {
|
||||
const mockOnStop = vi.fn();
|
||||
|
||||
const effectiveUser: User = {
|
||||
id: '2',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'owner',
|
||||
};
|
||||
|
||||
const originalUser: User = {
|
||||
id: '1',
|
||||
name: 'Admin User',
|
||||
email: 'admin@platform.com',
|
||||
role: 'superuser',
|
||||
};
|
||||
|
||||
const previousUser: User = {
|
||||
id: '3',
|
||||
name: 'Manager User',
|
||||
email: 'manager@example.com',
|
||||
role: 'platform_manager',
|
||||
const defaultProps = {
|
||||
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
|
||||
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
|
||||
previousUser: null,
|
||||
onStop: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the banner with correct structure', () => {
|
||||
const { container } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for main container - it's the first child div
|
||||
const banner = container.firstChild as HTMLElement;
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveClass('bg-orange-600', 'text-white');
|
||||
});
|
||||
|
||||
it('displays the Eye icon', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const eyeIcon = screen.getByTestId('eye-icon');
|
||||
expect(eyeIcon).toBeInTheDocument();
|
||||
expect(eyeIcon).toHaveAttribute('width', '18');
|
||||
expect(eyeIcon).toHaveAttribute('height', '18');
|
||||
});
|
||||
|
||||
it('displays the XCircle icon in the button', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const xCircleIcon = screen.getByTestId('xcircle-icon');
|
||||
expect(xCircleIcon).toBeInTheDocument();
|
||||
expect(xCircleIcon).toHaveAttribute('width', '14');
|
||||
expect(xCircleIcon).toHaveAttribute('height', '14');
|
||||
});
|
||||
it('renders effective user name', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('User Information Display', () => {
|
||||
it('displays the effective user name and role', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText(/owner/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the original user name', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays masquerading as message', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays different user roles correctly', () => {
|
||||
const staffUser: User = {
|
||||
id: '4',
|
||||
name: 'Staff Member',
|
||||
email: 'staff@example.com',
|
||||
role: 'staff',
|
||||
};
|
||||
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={staffUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Staff Member')).toBeInTheDocument();
|
||||
// Use a more specific query to avoid matching "Staff Member" text
|
||||
expect(screen.getByText(/\(staff\)/i)).toBeInTheDocument();
|
||||
});
|
||||
it('renders effective user role', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
// The role is split across elements: "(" + "staff" + ")"
|
||||
expect(screen.getByText(/staff/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Stop Masquerade Button', () => {
|
||||
it('renders the stop masquerade button when no previous user', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the return to user button when previous user exists', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={previousUser}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Return to Manager User/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onStop when button is clicked', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onStop when return button is clicked with previous user', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={previousUser}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Return to Manager User/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can be clicked multiple times', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnStop).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it('renders original user info', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Styling and Visual State', () => {
|
||||
it('has warning/info styling with orange background', () => {
|
||||
const { container } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const banner = container.firstChild as HTMLElement;
|
||||
expect(banner).toHaveClass('bg-orange-600');
|
||||
expect(banner).toHaveClass('text-white');
|
||||
});
|
||||
|
||||
it('has proper button styling', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
|
||||
expect(button).toHaveClass('bg-white');
|
||||
expect(button).toHaveClass('text-orange-600');
|
||||
expect(button).toHaveClass('hover:bg-orange-50');
|
||||
});
|
||||
|
||||
it('has animated pulse effect on Eye icon container', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const eyeIcon = screen.getByTestId('eye-icon');
|
||||
const iconContainer = eyeIcon.closest('div');
|
||||
expect(iconContainer).toHaveClass('animate-pulse');
|
||||
});
|
||||
|
||||
it('has proper layout classes for flexbox', () => {
|
||||
const { container } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const banner = container.firstChild as HTMLElement;
|
||||
expect(banner).toHaveClass('flex');
|
||||
expect(banner).toHaveClass('items-center');
|
||||
expect(banner).toHaveClass('justify-between');
|
||||
});
|
||||
|
||||
it('has z-index for proper stacking', () => {
|
||||
const { container } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const banner = container.firstChild as HTMLElement;
|
||||
expect(banner).toHaveClass('z-50');
|
||||
expect(banner).toHaveClass('relative');
|
||||
});
|
||||
|
||||
it('has shadow for visual prominence', () => {
|
||||
const { container } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const banner = container.firstChild as HTMLElement;
|
||||
expect(banner).toHaveClass('shadow-md');
|
||||
});
|
||||
it('calls onStop when button is clicked', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
const stopButton = screen.getByRole('button');
|
||||
fireEvent.click(stopButton);
|
||||
expect(defaultProps.onStop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles users with numeric IDs', () => {
|
||||
const numericIdUser: User = {
|
||||
id: 123,
|
||||
name: 'Numeric User',
|
||||
email: 'numeric@example.com',
|
||||
role: 'customer',
|
||||
};
|
||||
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={numericIdUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Numeric User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles users with long names', () => {
|
||||
const longNameUser: User = {
|
||||
id: '5',
|
||||
name: 'This Is A Very Long User Name That Should Still Display Properly',
|
||||
email: 'longname@example.com',
|
||||
role: 'manager',
|
||||
};
|
||||
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={longNameUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('This Is A Very Long User Name That Should Still Display Properly')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles all possible user roles', () => {
|
||||
const roles: Array<User['role']> = [
|
||||
'superuser',
|
||||
'platform_manager',
|
||||
'platform_support',
|
||||
'owner',
|
||||
'manager',
|
||||
'staff',
|
||||
'resource',
|
||||
'customer',
|
||||
];
|
||||
|
||||
roles.forEach((role) => {
|
||||
const { unmount } = render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={{ ...effectiveUser, role }}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(new RegExp(role, 'i'))).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles previousUser being null', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Stop Masquerading/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Return to/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles previousUser being defined', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={previousUser}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Return to Manager User/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stop Masquerading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
it('shows return to previous user text when previousUser exists', () => {
|
||||
const propsWithPrevious = {
|
||||
...defaultProps,
|
||||
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
|
||||
};
|
||||
render(<MasqueradeBanner {...propsWithPrevious} />);
|
||||
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has a clickable button element', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('button has descriptive text', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent(/Stop Masquerading/i);
|
||||
});
|
||||
|
||||
it('displays user information in semantic HTML', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
const strongElement = screen.getByText('John Doe');
|
||||
expect(strongElement.tagName).toBe('STRONG');
|
||||
});
|
||||
it('shows stop masquerading text when no previousUser', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('renders without crashing with minimal props', () => {
|
||||
const minimalEffectiveUser: User = {
|
||||
id: '1',
|
||||
name: 'Test',
|
||||
email: 'test@test.com',
|
||||
role: 'customer',
|
||||
};
|
||||
|
||||
const minimalOriginalUser: User = {
|
||||
id: '2',
|
||||
name: 'Admin',
|
||||
email: 'admin@test.com',
|
||||
role: 'superuser',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={minimalEffectiveUser}
|
||||
originalUser={minimalOriginalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders all required elements together', () => {
|
||||
render(
|
||||
<MasqueradeBanner
|
||||
effectiveUser={effectiveUser}
|
||||
originalUser={originalUser}
|
||||
previousUser={null}
|
||||
onStop={mockOnStop}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check all major elements are present
|
||||
expect(screen.getByTestId('eye-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('xcircle-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
it('renders with masquerading label', () => {
|
||||
render(<MasqueradeBanner {...defaultProps} />);
|
||||
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
463
frontend/src/components/__tests__/NotificationDropdown.test.tsx
Normal file
463
frontend/src/components/__tests__/NotificationDropdown.test.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import NotificationDropdown from '../NotificationDropdown';
|
||||
import { Notification } from '../../api/notifications';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-router-dom navigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock hooks
|
||||
const mockNotifications: Notification[] = [
|
||||
{
|
||||
id: 1,
|
||||
verb: 'created',
|
||||
read: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {},
|
||||
actor_type: 'user',
|
||||
actor_display: 'John Doe',
|
||||
target_type: 'appointment',
|
||||
target_display: 'Appointment with Jane',
|
||||
target_url: '/appointments/1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
verb: 'updated',
|
||||
read: true,
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
||||
data: {},
|
||||
actor_type: 'user',
|
||||
actor_display: 'Jane Smith',
|
||||
target_type: 'event',
|
||||
target_display: 'Meeting scheduled',
|
||||
target_url: '/events/2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
verb: 'created a ticket',
|
||||
read: false,
|
||||
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
|
||||
data: { ticket_id: '123' },
|
||||
actor_type: 'user',
|
||||
actor_display: 'Support Team',
|
||||
target_type: 'ticket',
|
||||
target_display: 'Ticket #123',
|
||||
target_url: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
verb: 'requested time off',
|
||||
read: false,
|
||||
timestamp: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days ago
|
||||
data: { type: 'time_off_request' },
|
||||
actor_type: 'user',
|
||||
actor_display: 'Bob Johnson',
|
||||
target_type: null,
|
||||
target_display: 'Time off request',
|
||||
target_url: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../../hooks/useNotifications', () => ({
|
||||
useNotifications: vi.fn(),
|
||||
useUnreadNotificationCount: vi.fn(),
|
||||
useMarkNotificationRead: vi.fn(),
|
||||
useMarkAllNotificationsRead: vi.fn(),
|
||||
useClearAllNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadNotificationCount,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
useClearAllNotifications,
|
||||
} from '../../hooks/useNotifications';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('NotificationDropdown', () => {
|
||||
const mockMarkRead = vi.fn();
|
||||
const mockMarkAllRead = vi.fn();
|
||||
const mockClearAll = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
data: mockNotifications,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||
data: 2,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useMarkNotificationRead).mockReturnValue({
|
||||
mutate: mockMarkRead,
|
||||
isPending: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
|
||||
mutate: mockMarkAllRead,
|
||||
isPending: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useClearAllNotifications).mockReturnValue({
|
||||
mutate: mockClearAll,
|
||||
isPending: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders bell icon button', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.getByRole('button', { name: /open notifications/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays unread count badge when there are unread notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display badge when unread count is 0', () => {
|
||||
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||
data: 0,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "99+" when unread count exceeds 99', () => {
|
||||
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||
data: 150,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render dropdown when closed', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown interactions', () => {
|
||||
it('opens dropdown when bell icon is clicked', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when close button is clicked', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||
fireEvent.click(closeButton!);
|
||||
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dropdown when clicking outside', async () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||
|
||||
// Simulate clicking outside
|
||||
fireEvent.mouseDown(document.body);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification list', () => {
|
||||
it('displays all notifications when dropdown is open', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays loading state', () => {
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
expect(screen.getByText('common.loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays empty state when no notifications', () => {
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
expect(screen.getByText('No notifications yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights unread notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const notificationButtons = screen.getAllByRole('button');
|
||||
const unreadNotification = notificationButtons.find(btn =>
|
||||
btn.textContent?.includes('John Doe')
|
||||
);
|
||||
|
||||
expect(unreadNotification).toHaveClass('bg-blue-50/50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification actions', () => {
|
||||
it('marks notification as read when clicked', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const notification = screen.getByText('John Doe').closest('button');
|
||||
fireEvent.click(notification!);
|
||||
|
||||
expect(mockMarkRead).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('navigates to target URL when notification is clicked', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const notification = screen.getByText('John Doe').closest('button');
|
||||
fireEvent.click(notification!);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/appointments/1');
|
||||
});
|
||||
|
||||
it('calls onTicketClick for ticket notifications', () => {
|
||||
const mockOnTicketClick = vi.fn();
|
||||
render(<NotificationDropdown onTicketClick={mockOnTicketClick} />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const ticketNotification = screen.getByText('Support Team').closest('button');
|
||||
fireEvent.click(ticketNotification!);
|
||||
|
||||
expect(mockOnTicketClick).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('navigates to time-blocks for time off requests', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
||||
fireEvent.click(timeOffNotification!);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/time-blocks');
|
||||
});
|
||||
|
||||
it('marks all notifications as read', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
// Find the mark all read button (CheckCheck icon)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markAllReadButton = buttons.find(btn =>
|
||||
btn.getAttribute('title')?.includes('Mark all as read')
|
||||
);
|
||||
|
||||
fireEvent.click(markAllReadButton!);
|
||||
expect(mockMarkAllRead).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears all read notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const clearButton = screen.getByText('Clear read');
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockClearAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates to notifications page when "View all" is clicked', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const viewAllButton = screen.getByText('View all');
|
||||
fireEvent.click(viewAllButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/notifications');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification icons', () => {
|
||||
it('displays Clock icon for time off requests', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
||||
const icon = timeOffNotification?.querySelector('svg');
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Ticket icon for ticket notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const ticketNotification = screen.getByText('Support Team').closest('button');
|
||||
const icon = ticketNotification?.querySelector('svg');
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays Calendar icon for event notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const eventNotification = screen.getByText('Jane Smith').closest('button');
|
||||
const icon = eventNotification?.querySelector('svg');
|
||||
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timestamp formatting', () => {
|
||||
it('displays "Just now" for recent notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
// The first notification is just now
|
||||
expect(screen.getByText('Just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time for older notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
// Check if notification timestamps are rendered
|
||||
// We have 4 notifications in our mock data, each should have a timestamp
|
||||
const notificationButtons = screen.getAllByRole('button').filter(btn =>
|
||||
btn.textContent?.includes('John Doe') ||
|
||||
btn.textContent?.includes('Jane Smith') ||
|
||||
btn.textContent?.includes('Support Team') ||
|
||||
btn.textContent?.includes('Bob Johnson')
|
||||
);
|
||||
|
||||
expect(notificationButtons.length).toBeGreaterThan(0);
|
||||
// At least one notification should have a timestamp
|
||||
const hasTimestamp = notificationButtons.some(btn => btn.textContent?.match(/Just now|\d+[hmd] ago|\d{1,2}\/\d{1,2}\/\d{4}/));
|
||||
expect(hasTimestamp).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variants', () => {
|
||||
it('renders with light variant', () => {
|
||||
render(<NotificationDropdown variant="light" />, { wrapper: createWrapper() });
|
||||
const button = screen.getByRole('button', { name: /open notifications/i });
|
||||
expect(button).toHaveClass('text-white/80');
|
||||
});
|
||||
|
||||
it('renders with dark variant (default)', () => {
|
||||
render(<NotificationDropdown variant="dark" />, { wrapper: createWrapper() });
|
||||
const button = screen.getByRole('button', { name: /open notifications/i });
|
||||
expect(button).toHaveClass('text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading states', () => {
|
||||
it('disables mark all read button when mutation is pending', () => {
|
||||
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
|
||||
mutate: mockMarkAllRead,
|
||||
isPending: true,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const markAllReadButton = buttons.find(btn =>
|
||||
btn.getAttribute('title')?.includes('Mark all as read')
|
||||
);
|
||||
|
||||
expect(markAllReadButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables clear all button when mutation is pending', () => {
|
||||
vi.mocked(useClearAllNotifications).mockReturnValue({
|
||||
mutate: mockClearAll,
|
||||
isPending: true,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
const clearButton = screen.getByText('Clear read');
|
||||
expect(clearButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer visibility', () => {
|
||||
it('shows footer when there are notifications', () => {
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
expect(screen.getByText('Clear read')).toBeInTheDocument();
|
||||
expect(screen.getByText('View all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides footer when there are no notifications', () => {
|
||||
vi.mocked(useNotifications).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||
|
||||
expect(screen.queryByText('Clear read')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('View all')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
577
frontend/src/components/__tests__/OAuthButtons.test.tsx
Normal file
577
frontend/src/components/__tests__/OAuthButtons.test.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* Unit tests for OAuthButtons component
|
||||
*
|
||||
* Tests OAuth provider buttons for social login.
|
||||
* Covers:
|
||||
* - Rendering providers from API
|
||||
* - Button clicks and OAuth initiation
|
||||
* - Loading states (initial load and button clicks)
|
||||
* - Provider-specific styling (colors, icons)
|
||||
* - Disabled state
|
||||
* - Error handling
|
||||
* - Empty state (no providers)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import OAuthButtons from '../OAuthButtons';
|
||||
|
||||
// Mock hooks
|
||||
const mockUseOAuthProviders = vi.fn();
|
||||
const mockUseInitiateOAuth = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/useOAuth', () => ({
|
||||
useOAuthProviders: () => mockUseOAuthProviders(),
|
||||
useInitiateOAuth: () => mockUseInitiateOAuth(),
|
||||
}));
|
||||
|
||||
// Helper to wrap component with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('OAuthButtons', () => {
|
||||
const mockMutate = vi.fn();
|
||||
const mockOnSuccess = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: false,
|
||||
variables: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner while fetching providers', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
// Look for the spinner SVG element with animate-spin class
|
||||
const spinner = container.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show providers while loading', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.queryByRole('button', { name: /continue with/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should render nothing when no providers are available', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render nothing when providers data is null', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider Rendering', () => {
|
||||
it('should render Google provider button', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple provider buttons', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
{ name: 'apple', display_name: 'Apple' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /continue with facebook/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /continue with apple/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply Google-specific styling (white bg, border)', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toHaveClass('bg-white', 'text-gray-900', 'border-gray-300');
|
||||
});
|
||||
|
||||
it('should apply Apple-specific styling (black bg)', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'apple', display_name: 'Apple' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with apple/i });
|
||||
expect(button).toHaveClass('bg-black', 'text-white');
|
||||
});
|
||||
|
||||
it('should apply Facebook-specific styling (blue bg)', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'facebook', display_name: 'Facebook' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with facebook/i });
|
||||
expect(button).toHaveClass('bg-[#1877F2]', 'text-white');
|
||||
});
|
||||
|
||||
it('should apply LinkedIn-specific styling', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'linkedin', display_name: 'LinkedIn' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with linkedin/i });
|
||||
expect(button).toHaveClass('bg-[#0A66C2]', 'text-white');
|
||||
});
|
||||
|
||||
it('should render unknown provider with fallback styling', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'custom_provider', display_name: 'Custom Provider' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with custom provider/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-gray-600', 'text-white');
|
||||
});
|
||||
|
||||
it('should display provider icons', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
// Icons should be present (rendered as text in config)
|
||||
expect(screen.getByText('G')).toBeInTheDocument(); // Google icon
|
||||
expect(screen.getByText('f')).toBeInTheDocument(); // Facebook icon
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Clicks', () => {
|
||||
it('should call OAuth initiation when button is clicked', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should call onSuccess callback after successful OAuth initiation', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockMutate.mockImplementation((provider, { onSuccess }) => {
|
||||
onSuccess?.();
|
||||
});
|
||||
|
||||
render(<OAuthButtons onSuccess={mockOnSuccess} />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle multiple provider clicks', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const googleButton = screen.getByRole('button', { name: /continue with google/i });
|
||||
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
|
||||
|
||||
fireEvent.click(googleButton);
|
||||
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
|
||||
|
||||
fireEvent.click(facebookButton);
|
||||
expect(mockMutate).toHaveBeenCalledWith('facebook', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not initiate OAuth when button is disabled', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initiate OAuth when another button is pending', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /connecting/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should not call mutate again
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State During OAuth', () => {
|
||||
it('should show loading state on clicked button', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/continue with google/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show spinner icon during loading', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
// Loader2 icon should be rendered
|
||||
const spinner = container.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only show loading on the clicked button', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
// Google button should show loading
|
||||
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||
|
||||
// Facebook button should still show normal text
|
||||
expect(screen.getByText(/continue with facebook/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should disable all buttons when disabled prop is true', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||
|
||||
const googleButton = screen.getByRole('button', { name: /continue with google/i });
|
||||
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
|
||||
|
||||
expect(googleButton).toBeDisabled();
|
||||
expect(facebookButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should apply disabled styling when disabled', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('should disable all buttons during OAuth pending', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should log error on OAuth initiation failure', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const error = new Error('OAuth error');
|
||||
mockMutate.mockImplementation((provider, { onError }) => {
|
||||
onError?.(error);
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('OAuth initiation error:', error);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider Variants', () => {
|
||||
it('should render Microsoft provider', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'microsoft', display_name: 'Microsoft' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with microsoft/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-[#00A4EF]');
|
||||
});
|
||||
|
||||
it('should render X (Twitter) provider', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'x', display_name: 'X' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with x/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-black');
|
||||
});
|
||||
|
||||
it('should render Twitch provider', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'twitch', display_name: 'Twitch' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with twitch/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-[#9146FF]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Styling', () => {
|
||||
it('should have consistent button styling', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toHaveClass(
|
||||
'w-full',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'rounded-lg',
|
||||
'shadow-sm'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hover transition styles', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toHaveClass('transition-all', 'duration-200');
|
||||
});
|
||||
|
||||
it('should have focus ring styles', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have button role for all providers', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [
|
||||
{ name: 'google', display_name: 'Google' },
|
||||
{ name: 'facebook', display_name: 'Facebook' },
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should have descriptive button text', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should indicate loading state to screen readers', () => {
|
||||
mockUseOAuthProviders.mockReturnValue({
|
||||
data: [{ name: 'google', display_name: 'Google' }],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseInitiateOAuth.mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
variables: 'google',
|
||||
});
|
||||
|
||||
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
827
frontend/src/components/__tests__/OnboardingWizard.test.tsx
Normal file
827
frontend/src/components/__tests__/OnboardingWizard.test.tsx
Normal file
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Unit tests for OnboardingWizard component
|
||||
*
|
||||
* Tests the multi-step onboarding wizard for new businesses.
|
||||
* Covers:
|
||||
* - Step navigation (welcome -> stripe -> complete)
|
||||
* - Step indicator visualization
|
||||
* - Welcome step rendering and buttons
|
||||
* - Stripe Connect integration step
|
||||
* - Completion step
|
||||
* - Skip functionality
|
||||
* - Auto-advance on Stripe connection
|
||||
* - URL parameter handling (OAuth callback)
|
||||
* - Loading states
|
||||
* - Business update on completion
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, useSearchParams } from 'react-router-dom';
|
||||
import OnboardingWizard from '../OnboardingWizard';
|
||||
import { Business } from '../../types';
|
||||
|
||||
// Mock hooks
|
||||
const mockUsePaymentConfig = vi.fn();
|
||||
const mockUseUpdateBusiness = vi.fn();
|
||||
const mockSetSearchParams = vi.fn();
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock('../../hooks/usePayments', () => ({
|
||||
usePaymentConfig: () => mockUsePaymentConfig(),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useBusiness', () => ({
|
||||
useUpdateBusiness: () => mockUseUpdateBusiness(),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ConnectOnboardingEmbed component
|
||||
vi.mock('../ConnectOnboardingEmbed', () => ({
|
||||
default: ({ onComplete, onError }: any) => (
|
||||
<div data-testid="connect-embed">
|
||||
<button onClick={() => onComplete()}>Complete Embed</button>
|
||||
<button onClick={() => onError('Test error')}>Trigger Error</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'onboarding.steps.welcome': 'Welcome',
|
||||
'onboarding.steps.payments': 'Payments',
|
||||
'onboarding.steps.complete': 'Complete',
|
||||
'onboarding.welcome.title': `Welcome to ${params?.businessName}!`,
|
||||
'onboarding.welcome.subtitle': "Let's get you set up",
|
||||
'onboarding.welcome.whatsIncluded': "What's Included",
|
||||
'onboarding.welcome.connectStripe': 'Connect to Stripe',
|
||||
'onboarding.welcome.automaticPayouts': 'Automatic payouts',
|
||||
'onboarding.welcome.pciCompliance': 'PCI compliance',
|
||||
'onboarding.welcome.getStarted': 'Get Started',
|
||||
'onboarding.welcome.skip': 'Skip for now',
|
||||
'onboarding.stripe.title': 'Connect Stripe',
|
||||
'onboarding.stripe.subtitle': `Accept payments with your ${params?.plan} plan`,
|
||||
'onboarding.stripe.checkingStatus': 'Checking status...',
|
||||
'onboarding.stripe.connected.title': 'Connected!',
|
||||
'onboarding.stripe.connected.subtitle': 'Your account is ready',
|
||||
'onboarding.stripe.continue': 'Continue',
|
||||
'onboarding.stripe.doLater': 'Do this later',
|
||||
'onboarding.complete.title': "You're all set!",
|
||||
'onboarding.complete.subtitle': 'Ready to start',
|
||||
'onboarding.complete.checklist.accountCreated': 'Account created',
|
||||
'onboarding.complete.checklist.stripeConfigured': 'Stripe configured',
|
||||
'onboarding.complete.checklist.readyForPayments': 'Ready for payments',
|
||||
'onboarding.complete.goToDashboard': 'Go to Dashboard',
|
||||
'onboarding.skipForNow': 'Skip for now',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data factory
|
||||
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#1E40AF',
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: false,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
plan: 'Professional',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Helper to wrap component with providers
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('OnboardingWizard', () => {
|
||||
const mockOnComplete = vi.fn();
|
||||
const mockOnSkip = vi.fn();
|
||||
const mockRefetch = vi.fn();
|
||||
const mockMutateAsync = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.delete('connect');
|
||||
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: null,
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
mockUseUpdateBusiness.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Rendering', () => {
|
||||
it('should render modal overlay', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Modal has the fixed class for overlay
|
||||
const modal = container.querySelector('.fixed');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toHaveClass('inset-0');
|
||||
});
|
||||
|
||||
it('should render close button', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const closeButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.querySelector('svg')
|
||||
);
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have scrollable content', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const modal = container.querySelector('.overflow-auto');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Indicator', () => {
|
||||
it('should render step indicator with 3 steps', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const stepCircles = container.querySelectorAll('.rounded-full.w-8.h-8');
|
||||
expect(stepCircles.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should highlight current step', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const activeStep = container.querySelector('.bg-blue-600');
|
||||
expect(activeStep).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show completed steps with checkmark', async () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Move to next step
|
||||
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
// First step should show green background after navigation
|
||||
await waitFor(() => {
|
||||
const completedStep = container.querySelector('.bg-green-500');
|
||||
expect(completedStep).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Welcome Step', () => {
|
||||
it('should render welcome step by default', () => {
|
||||
const business = createMockBusiness({ name: 'Test Business' });
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/welcome to test business/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sparkles icon', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const iconCircle = container.querySelector('.bg-gradient-to-br.from-blue-500');
|
||||
expect(iconCircle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show features list', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/connect to stripe/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/automatic payouts/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pci compliance/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Get Started button', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /get started/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-blue-600');
|
||||
});
|
||||
|
||||
it('should render Skip button', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Look for the skip button with exact text (not the close button with title)
|
||||
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||
expect(skipButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should advance to stripe step on Get Started click', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||
fireEvent.click(getStartedButton);
|
||||
|
||||
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stripe Connect Step', () => {
|
||||
beforeEach(() => {
|
||||
// Start at Stripe step
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||
fireEvent.click(getStartedButton);
|
||||
});
|
||||
|
||||
it('should render Stripe step after welcome', () => {
|
||||
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading while checking status', () => {
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to stripe step
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
expect(screen.getByText(/checking status/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ConnectOnboardingEmbed when not connected', () => {
|
||||
expect(screen.getByTestId('connect-embed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show success message when already connected', () => {
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
// Component auto-advances to complete step when already connected
|
||||
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Do This Later button', () => {
|
||||
expect(screen.getByRole('button', { name: /do this later/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle embedded onboarding completion', async () => {
|
||||
const completeButton = screen.getByText('Complete Embed');
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle embedded onboarding error', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const errorButton = screen.getByText('Trigger Error');
|
||||
fireEvent.click(errorButton);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Embedded onboarding error:', 'Test error');
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete Step', () => {
|
||||
it('should render complete step when Stripe is connected', () => {
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to stripe step - will auto-advance to complete since connected
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show completion checklist', () => {
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/stripe configured/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ready for payments/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Go to Dashboard button', () => {
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /go to dashboard/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onComplete when dashboard button clicked', async () => {
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
const dashboardButton = screen.getByRole('button', { name: /go to dashboard/i });
|
||||
fireEvent.click(dashboardButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skip Functionality', () => {
|
||||
it('should call onSkip when skip button clicked on welcome', async () => {
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
onSkip={mockOnSkip}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Find the text-based skip button (not the X close button)
|
||||
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||
if (skipButton) {
|
||||
fireEvent.click(skipButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||
expect(mockOnSkip).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onComplete when no onSkip provided', async () => {
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||
if (skipButton) {
|
||||
fireEvent.click(skipButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnComplete).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should update business setup complete flag on skip', async () => {
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||
if (skipButton) {
|
||||
fireEvent.click(skipButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should close wizard when X button clicked', async () => {
|
||||
mockMutateAsync.mockResolvedValue({});
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Find X button (close button)
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const xButton = closeButtons.find(btn => btn.querySelector('svg') && !btn.textContent?.trim());
|
||||
|
||||
if (xButton) {
|
||||
fireEvent.click(xButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-advance on Stripe Connection', () => {
|
||||
it('should auto-advance to complete when Stripe connects', async () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
// Start not connected
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: null,
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to stripe step
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
// Simulate Stripe connection
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Parameter Handling', () => {
|
||||
it('should handle connect=complete query parameter', () => {
|
||||
mockSearchParams.set('connect', 'complete');
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
expect(mockSetSearchParams).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('should handle connect=refresh query parameter', () => {
|
||||
mockSearchParams.set('connect', 'refresh');
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
expect(mockSetSearchParams).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should disable dashboard button while updating', () => {
|
||||
mockUseUpdateBusiness.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
mockUsePaymentConfig.mockReturnValue({
|
||||
data: {
|
||||
connect_account: {
|
||||
status: 'active',
|
||||
charges_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
const business = createMockBusiness();
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||
|
||||
// Dashboard button should be disabled while updating
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard') || btn.querySelector('.animate-spin'));
|
||||
if (dashboardButton) {
|
||||
expect(dashboardButton).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper modal structure', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
const { container } = render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Modal overlay with fixed positioning
|
||||
const modalOverlay = container.querySelector('.fixed.z-50');
|
||||
expect(modalOverlay).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have semantic headings', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
const business = createMockBusiness();
|
||||
|
||||
render(
|
||||
<OnboardingWizard
|
||||
business={business}
|
||||
onComplete={mockOnComplete}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
481
frontend/src/components/__tests__/ResourceCalendar.test.tsx
Normal file
481
frontend/src/components/__tests__/ResourceCalendar.test.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import ResourceCalendar from '../ResourceCalendar';
|
||||
import { Appointment } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Portal component
|
||||
vi.mock('../Portal', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock date-fns to control time-based tests
|
||||
vi.mock('date-fns', async () => {
|
||||
const actual = await vi.importActual('date-fns');
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
});
|
||||
|
||||
// Use today's date for appointments so they show up in the calendar
|
||||
const today = new Date();
|
||||
today.setHours(10, 0, 0, 0);
|
||||
|
||||
const mockAppointments: Appointment[] = [
|
||||
{
|
||||
id: '1',
|
||||
resourceId: 'resource-1',
|
||||
customerId: 'customer-1',
|
||||
customerName: 'John Doe',
|
||||
serviceId: 'service-1',
|
||||
startTime: new Date(today.getTime()),
|
||||
durationMinutes: 60,
|
||||
status: 'SCHEDULED',
|
||||
notes: 'First appointment',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalPrice: null,
|
||||
finalChargeTransactionId: '',
|
||||
isVariablePricing: false,
|
||||
remainingBalance: null,
|
||||
overpaidAmount: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
resourceId: 'resource-1',
|
||||
customerId: 'customer-2',
|
||||
customerName: 'Jane Smith',
|
||||
serviceId: 'service-2',
|
||||
startTime: new Date(today.getTime() + 4.5 * 60 * 60 * 1000), // 14:30
|
||||
durationMinutes: 90,
|
||||
status: 'SCHEDULED',
|
||||
notes: 'Second appointment',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalPrice: null,
|
||||
finalChargeTransactionId: '',
|
||||
isVariablePricing: false,
|
||||
remainingBalance: null,
|
||||
overpaidAmount: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
resourceId: 'resource-2',
|
||||
customerId: 'customer-3',
|
||||
customerName: 'Bob Johnson',
|
||||
serviceId: 'service-1',
|
||||
startTime: new Date(today.getTime() + 1 * 60 * 60 * 1000), // 11:00
|
||||
durationMinutes: 45,
|
||||
status: 'SCHEDULED',
|
||||
notes: 'Different resource',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalPrice: null,
|
||||
finalChargeTransactionId: '',
|
||||
isVariablePricing: false,
|
||||
remainingBalance: null,
|
||||
overpaidAmount: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('../../hooks/useAppointments', () => ({
|
||||
useAppointments: vi.fn(),
|
||||
useUpdateAppointment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ResourceCalendar', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockUpdateMutate = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'Dr. Smith',
|
||||
onClose: mockOnClose,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: mockAppointments,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useUpdateAppointment).mockReturnValue({
|
||||
mutate: mockUpdateMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders calendar modal', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays close button', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const closeButtons = screen.getAllByRole('button');
|
||||
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||
fireEvent.click(closeButton!);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays resource name in title', () => {
|
||||
render(<ResourceCalendar {...defaultProps} resourceName="Conference Room A" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(screen.getByText('Conference Room A Calendar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View modes', () => {
|
||||
it('renders day view by default', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const dayButton = screen.getByRole('button', { name: /^day$/i });
|
||||
expect(dayButton).toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('switches to week view when week button is clicked', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const weekButton = screen.getByRole('button', { name: /^week$/i });
|
||||
fireEvent.click(weekButton);
|
||||
expect(weekButton).toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('switches to month view when month button is clicked', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const monthButton = screen.getByRole('button', { name: /^month$/i });
|
||||
fireEvent.click(monthButton);
|
||||
expect(monthButton).toHaveClass('bg-white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('displays Today button', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByRole('button', { name: /today/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays previous and next navigation buttons', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const navButtons = buttons.filter(btn => btn.querySelector('svg'));
|
||||
expect(navButtons.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('navigates to previous day in day view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const prevButton = buttons.find(btn => {
|
||||
const svg = btn.querySelector('svg');
|
||||
return svg && btn.querySelector('[class*="ChevronLeft"]');
|
||||
});
|
||||
|
||||
// Initial date rendering
|
||||
const initialText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
|
||||
const initialDate = initialText.textContent;
|
||||
|
||||
if (prevButton) {
|
||||
fireEvent.click(prevButton);
|
||||
const newText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
|
||||
expect(newText.textContent).not.toBe(initialDate);
|
||||
}
|
||||
});
|
||||
|
||||
it('clicks Today button to reset to current date', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const todayButton = screen.getByRole('button', { name: /today/i });
|
||||
fireEvent.click(todayButton);
|
||||
// Should display current date
|
||||
expect(screen.getByText(/\w+, \w+ \d+, \d{4}/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Appointments display', () => {
|
||||
it('displays appointments for the selected resource', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters out appointments for other resources', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays appointment customer names', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays appointment time and duration', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
// Check for time format (e.g., "10:00 AM • 60 min")
|
||||
// Use getAllByText since there might be multiple appointments with same duration
|
||||
const timeElements = screen.getAllByText(/10:00 AM/);
|
||||
expect(timeElements.length).toBeGreaterThan(0);
|
||||
const durationElements = screen.getAllByText(/1h/);
|
||||
expect(durationElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading states', () => {
|
||||
it('displays loading message when loading', () => {
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('scheduler.loadingAppointments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays empty state when no appointments', () => {
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('scheduler.noAppointmentsScheduled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Week view', () => {
|
||||
it('renders week view when week button is clicked', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
const weekButton = screen.getByRole('button', { name: /^week$/i });
|
||||
fireEvent.click(weekButton);
|
||||
|
||||
// Verify week button is active (has bg-white class)
|
||||
expect(weekButton).toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('week view shows different content than day view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Get content in day view
|
||||
const dayViewContent = document.body.textContent || '';
|
||||
|
||||
// Switch to week view
|
||||
fireEvent.click(screen.getByRole('button', { name: /^week$/i }));
|
||||
|
||||
// Get content in week view
|
||||
const weekViewContent = document.body.textContent || '';
|
||||
|
||||
// Week view and day view should have different content
|
||||
// (Week view shows multiple days, day view shows single day timeline)
|
||||
expect(weekViewContent).not.toBe(dayViewContent);
|
||||
|
||||
// Week view should show hint text for clicking days
|
||||
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Month view', () => {
|
||||
it('displays calendar grid in month view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /month/i }));
|
||||
|
||||
// Should show weekday headers
|
||||
expect(screen.getByText('Mon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows appointment count in month view cells', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /month/i }));
|
||||
|
||||
// Should show "2 appts" for the day with 2 appointments
|
||||
expect(screen.getByText(/2 appt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a day in month view switches to week view', async () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /month/i }));
|
||||
|
||||
// Find day cells and click one
|
||||
const dayCells = screen.getAllByText(/^\d+$/);
|
||||
if (dayCells.length > 0) {
|
||||
fireEvent.click(dayCells[0].closest('div')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const weekButton = screen.getByRole('button', { name: /week/i });
|
||||
expect(weekButton).toHaveClass('bg-white');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drag and drop (day view)', () => {
|
||||
it('displays drag hint in day view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/drag to move/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays click hint in week/month view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
fireEvent.click(screen.getByRole('button', { name: /week/i }));
|
||||
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Appointment interactions', () => {
|
||||
it('renders appointments with appropriate styling in day view', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Verify appointments are rendered
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
|
||||
// Verify they have parent elements (appointment containers)
|
||||
const appointment1 = screen.getByText('John Doe').parentElement;
|
||||
const appointment2 = screen.getByText('Jane Smith').parentElement;
|
||||
|
||||
expect(appointment1).toBeInTheDocument();
|
||||
expect(appointment2).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Duration formatting', () => {
|
||||
it('formats duration less than 60 minutes as minutes', () => {
|
||||
const shortAppointment: Appointment = {
|
||||
...mockAppointments[0],
|
||||
durationMinutes: 45,
|
||||
};
|
||||
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: [shortAppointment],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/45 min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats duration 60+ minutes as hours', () => {
|
||||
const longAppointment: Appointment = {
|
||||
...mockAppointments[0],
|
||||
durationMinutes: 120,
|
||||
};
|
||||
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: [longAppointment],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/2h/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats duration with hours and minutes', () => {
|
||||
const mixedAppointment: Appointment = {
|
||||
...mockAppointments[0],
|
||||
durationMinutes: 90,
|
||||
};
|
||||
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: [mixedAppointment],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText(/1h 30m/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible button labels', () => {
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByRole('button', { name: /^day$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^week$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^month$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^today$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlapping appointments', () => {
|
||||
it('handles overlapping appointments with lane layout', () => {
|
||||
const todayAt10 = new Date();
|
||||
todayAt10.setHours(10, 0, 0, 0);
|
||||
const todayAt1030 = new Date();
|
||||
todayAt1030.setHours(10, 30, 0, 0);
|
||||
|
||||
const overlappingAppointments: Appointment[] = [
|
||||
{
|
||||
...mockAppointments[0],
|
||||
startTime: todayAt10,
|
||||
durationMinutes: 120,
|
||||
},
|
||||
{
|
||||
...mockAppointments[1],
|
||||
id: '2',
|
||||
startTime: todayAt1030,
|
||||
durationMinutes: 60,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAppointments).mockReturnValue({
|
||||
data: overlappingAppointments,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props variations', () => {
|
||||
it('works with different resource IDs', () => {
|
||||
render(<ResourceCalendar {...defaultProps} resourceId="resource-2" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates when resource name changes', () => {
|
||||
const { rerender } = render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
|
||||
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ResourceCalendar {...defaultProps} resourceName="Dr. Jones" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
expect(screen.getByText('Dr. Jones Calendar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
86
frontend/src/components/__tests__/SandboxBanner.test.tsx
Normal file
86
frontend/src/components/__tests__/SandboxBanner.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import SandboxBanner from '../SandboxBanner';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SandboxBanner', () => {
|
||||
const defaultProps = {
|
||||
isSandbox: true,
|
||||
onSwitchToLive: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders when in sandbox mode', () => {
|
||||
render(<SandboxBanner {...defaultProps} />);
|
||||
expect(screen.getByText('TEST MODE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when not in sandbox mode', () => {
|
||||
const { container } = render(<SandboxBanner {...defaultProps} isSandbox={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders banner description', () => {
|
||||
render(<SandboxBanner {...defaultProps} />);
|
||||
expect(screen.getByText(/You are viewing test data/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders switch to live button', () => {
|
||||
render(<SandboxBanner {...defaultProps} />);
|
||||
expect(screen.getByText('Switch to Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSwitchToLive when button clicked', () => {
|
||||
const onSwitchToLive = vi.fn();
|
||||
render(<SandboxBanner {...defaultProps} onSwitchToLive={onSwitchToLive} />);
|
||||
fireEvent.click(screen.getByText('Switch to Live'));
|
||||
expect(onSwitchToLive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables button when switching', () => {
|
||||
render(<SandboxBanner {...defaultProps} isSwitching />);
|
||||
expect(screen.getByText('Switching...')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows switching text when isSwitching is true', () => {
|
||||
render(<SandboxBanner {...defaultProps} isSwitching />);
|
||||
expect(screen.getByText('Switching...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dismiss button when onDismiss provided', () => {
|
||||
render(<SandboxBanner {...defaultProps} onDismiss={() => {}} />);
|
||||
expect(screen.getByTitle('Dismiss')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render dismiss button when onDismiss not provided', () => {
|
||||
render(<SandboxBanner {...defaultProps} />);
|
||||
expect(screen.queryByTitle('Dismiss')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDismiss when dismiss button clicked', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<SandboxBanner {...defaultProps} onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByTitle('Dismiss'));
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has gradient background', () => {
|
||||
const { container } = render(<SandboxBanner {...defaultProps} />);
|
||||
expect(container.firstChild).toHaveClass('bg-gradient-to-r');
|
||||
});
|
||||
|
||||
it('renders flask icon', () => {
|
||||
const { container } = render(<SandboxBanner {...defaultProps} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/__tests__/SandboxToggle.test.tsx
Normal file
108
frontend/src/components/__tests__/SandboxToggle.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import SandboxToggle from '../SandboxToggle';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SandboxToggle', () => {
|
||||
const defaultProps = {
|
||||
isSandbox: false,
|
||||
sandboxEnabled: true,
|
||||
onToggle: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders when sandbox is enabled', () => {
|
||||
render(<SandboxToggle {...defaultProps} />);
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when sandbox not enabled', () => {
|
||||
const { container } = render(<SandboxToggle {...defaultProps} sandboxEnabled={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('highlights Live button when not in sandbox mode', () => {
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
|
||||
const liveButton = screen.getByText('Live').closest('button');
|
||||
expect(liveButton).toHaveClass('bg-green-600');
|
||||
});
|
||||
|
||||
it('highlights Test button when in sandbox mode', () => {
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
|
||||
const testButton = screen.getByText('Test').closest('button');
|
||||
expect(testButton).toHaveClass('bg-orange-500');
|
||||
});
|
||||
|
||||
it('calls onToggle with false when Live clicked', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={true} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByText('Live'));
|
||||
expect(onToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('calls onToggle with true when Test clicked', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('disables Live button when already in live mode', () => {
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
|
||||
const liveButton = screen.getByText('Live').closest('button');
|
||||
expect(liveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Test button when already in sandbox mode', () => {
|
||||
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
|
||||
const testButton = screen.getByText('Test').closest('button');
|
||||
expect(testButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables both buttons when toggling', () => {
|
||||
render(<SandboxToggle {...defaultProps} isToggling />);
|
||||
const liveButton = screen.getByText('Live').closest('button');
|
||||
const testButton = screen.getByText('Test').closest('button');
|
||||
expect(liveButton).toBeDisabled();
|
||||
expect(testButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies opacity when toggling', () => {
|
||||
render(<SandboxToggle {...defaultProps} isToggling />);
|
||||
const liveButton = screen.getByText('Live').closest('button');
|
||||
expect(liveButton).toHaveClass('opacity-50');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<SandboxToggle {...defaultProps} className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('has title for Live button', () => {
|
||||
render(<SandboxToggle {...defaultProps} />);
|
||||
const liveButton = screen.getByText('Live').closest('button');
|
||||
expect(liveButton).toHaveAttribute('title', 'Live Mode - Production data');
|
||||
});
|
||||
|
||||
it('has title for Test button', () => {
|
||||
render(<SandboxToggle {...defaultProps} />);
|
||||
const testButton = screen.getByText('Test').closest('button');
|
||||
expect(testButton).toHaveAttribute('title', 'Test Mode - Sandbox data');
|
||||
});
|
||||
|
||||
it('renders icons', () => {
|
||||
const { container } = render(<SandboxToggle {...defaultProps} />);
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBe(2); // Zap and FlaskConical icons
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
||||
|
||||
describe('SmoothScheduleLogo', () => {
|
||||
it('renders an SVG element', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct viewBox', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('viewBox', '0 0 1730 1100');
|
||||
});
|
||||
|
||||
it('uses currentColor for fill', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('fill', 'currentColor');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<SmoothScheduleLogo className="custom-logo-class" />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveClass('custom-logo-class');
|
||||
});
|
||||
|
||||
it('renders without className when not provided', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('contains path elements', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const paths = container.querySelectorAll('path');
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has xmlns attribute', () => {
|
||||
const { container } = render(<SmoothScheduleLogo />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
});
|
||||
});
|
||||
716
frontend/src/components/__tests__/TopBar.test.tsx
Normal file
716
frontend/src/components/__tests__/TopBar.test.tsx
Normal file
@@ -0,0 +1,716 @@
|
||||
/**
|
||||
* Unit tests for TopBar component
|
||||
*
|
||||
* Tests the top navigation bar that appears at the top of the application.
|
||||
* Covers:
|
||||
* - Rendering of all UI elements (search, theme toggle, notifications, etc.)
|
||||
* - Menu button for mobile view
|
||||
* - Theme toggle functionality
|
||||
* - User profile dropdown integration
|
||||
* - Language selector integration
|
||||
* - Notification dropdown integration
|
||||
* - Sandbox toggle integration
|
||||
* - Search input
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TopBar from '../TopBar';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../UserProfileDropdown', () => ({
|
||||
default: ({ user }: { user: User }) => (
|
||||
<div data-testid="user-profile-dropdown">User: {user.email}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../LanguageSelector', () => ({
|
||||
default: () => <div data-testid="language-selector">Language Selector</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../NotificationDropdown', () => ({
|
||||
default: ({ onTicketClick }: { onTicketClick?: (id: string) => void }) => (
|
||||
<div data-testid="notification-dropdown">Notifications</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../SandboxToggle', () => ({
|
||||
default: ({ isSandbox, sandboxEnabled, onToggle, isToggling }: any) => (
|
||||
<div data-testid="sandbox-toggle">
|
||||
Sandbox: {isSandbox ? 'On' : 'Off'}
|
||||
<button onClick={onToggle} disabled={isToggling}>
|
||||
Toggle Sandbox
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock SandboxContext
|
||||
const mockUseSandbox = vi.fn();
|
||||
vi.mock('../../contexts/SandboxContext', () => ({
|
||||
useSandbox: () => mockUseSandbox(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'common.search': 'Search...',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data factory for User objects
|
||||
const createMockUser = (overrides?: Partial<User>): User => ({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
role: 'owner',
|
||||
phone: '+1234567890',
|
||||
preferences: {
|
||||
email: true,
|
||||
sms: false,
|
||||
in_app: true,
|
||||
},
|
||||
twoFactorEnabled: false,
|
||||
profilePictureUrl: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Wrapper component that provides router context
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('TopBar', () => {
|
||||
const mockToggleTheme = vi.fn();
|
||||
const mockOnMenuClick = vi.fn();
|
||||
const mockOnTicketClick = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseSandbox.mockReturnValue({
|
||||
isSandbox: false,
|
||||
sandboxEnabled: true,
|
||||
toggleSandbox: vi.fn(),
|
||||
isToggling: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the top bar with all main elements', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render search input on desktop', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('should render mobile menu button', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass user to UserProfileDropdown', () => {
|
||||
const user = createMockUser({ email: 'john@example.com' });
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('User: john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with dark mode styles when isDarkMode is true', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={true}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Toggle', () => {
|
||||
it('should render moon icon when in light mode', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
// The button should exist
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const themeButton = buttons.find(btn =>
|
||||
btn.className.includes('text-gray-400')
|
||||
);
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sun icon when in dark mode', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={true}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
// The button should exist
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const themeButton = buttons.find(btn =>
|
||||
btn.className.includes('text-gray-400')
|
||||
);
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call toggleTheme when theme button is clicked', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the theme toggle button by finding buttons, then clicking the one with the theme classes
|
||||
const buttons = screen.getAllByRole('button');
|
||||
// The theme button is the one with the hover styles and not the menu button
|
||||
const themeButton = buttons.find(btn =>
|
||||
btn.className.includes('text-gray-400') &&
|
||||
btn.className.includes('hover:text-gray-600') &&
|
||||
!btn.getAttribute('aria-label')
|
||||
);
|
||||
|
||||
expect(themeButton).toBeTruthy();
|
||||
if (themeButton) {
|
||||
fireEvent.click(themeButton);
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Menu Button', () => {
|
||||
it('should render menu button with correct aria-label', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
|
||||
});
|
||||
|
||||
it('should call onMenuClick when menu button is clicked', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
expect(mockOnMenuClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have mobile-only classes on menu button', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveClass('md:hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Input', () => {
|
||||
it('should render search input with correct placeholder', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
it('should have search icon', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
// Search icon should be present
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput.parentElement?.querySelector('span')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow typing in search input', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...') as HTMLInputElement;
|
||||
fireEvent.change(searchInput, { target: { value: 'test query' } });
|
||||
|
||||
expect(searchInput.value).toBe('test query');
|
||||
});
|
||||
|
||||
it('should have focus styles on search input', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toHaveClass('focus:outline-none', 'focus:border-brand-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox Integration', () => {
|
||||
it('should render SandboxToggle component', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass sandbox state to SandboxToggle', () => {
|
||||
const user = createMockUser();
|
||||
mockUseSandbox.mockReturnValue({
|
||||
isSandbox: true,
|
||||
sandboxEnabled: true,
|
||||
toggleSandbox: vi.fn(),
|
||||
isToggling: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Sandbox: On/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle sandbox toggle being disabled', () => {
|
||||
const user = createMockUser();
|
||||
mockUseSandbox.mockReturnValue({
|
||||
isSandbox: false,
|
||||
sandboxEnabled: false,
|
||||
toggleSandbox: vi.fn(),
|
||||
isToggling: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification Integration', () => {
|
||||
it('should render NotificationDropdown', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass onTicketClick to NotificationDropdown when provided', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
onTicketClick={mockOnTicketClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work without onTicketClick prop', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language Selector Integration', () => {
|
||||
it('should render LanguageSelector', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different User Roles', () => {
|
||||
it('should render for owner role', () => {
|
||||
const user = createMockUser({ role: 'owner' });
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render for manager role', () => {
|
||||
const user = createMockUser({ role: 'manager' });
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render for staff role', () => {
|
||||
const user = createMockUser({ role: 'staff' });
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render for platform roles', () => {
|
||||
const user = createMockUser({ role: 'platform_manager' });
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Styling', () => {
|
||||
it('should have fixed height', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('h-16');
|
||||
});
|
||||
|
||||
it('should have border at bottom', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('border-b');
|
||||
});
|
||||
|
||||
it('should use flexbox layout', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
|
||||
});
|
||||
|
||||
it('should have responsive padding', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const header = container.querySelector('header');
|
||||
expect(header).toHaveClass('px-4', 'sm:px-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic header element', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper button roles', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have focus styles on interactive elements', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveClass('focus:outline-none', 'focus:ring-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Behavior', () => {
|
||||
it('should hide search on mobile', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
const { container } = renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
// Search container is a relative div with hidden md:block classes
|
||||
const searchContainer = container.querySelector('.hidden.md\\:block');
|
||||
expect(searchContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show menu button only on mobile', () => {
|
||||
const user = createMockUser();
|
||||
|
||||
renderWithRouter(
|
||||
<TopBar
|
||||
user={user}
|
||||
isDarkMode={false}
|
||||
toggleTheme={mockToggleTheme}
|
||||
onMenuClick={mockOnMenuClick}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = screen.getByLabelText('Open sidebar');
|
||||
expect(menuButton).toHaveClass('md:hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -508,4 +508,230 @@ describe('TrialBanner', () => {
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Edge Cases', () => {
|
||||
it('should handle negative days left gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: -5,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should still render (backend shouldn't send this, but defensive coding)
|
||||
expect(screen.getByText(/-5 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle fractional days by rounding', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5.7 as number,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should display with the value received
|
||||
expect(screen.getByText(/5.7 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should transition from urgent to non-urgent styling on update', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 3,
|
||||
});
|
||||
|
||||
const { container, rerender } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Initially urgent
|
||||
expect(container.querySelector('.from-red-500')).toBeInTheDocument();
|
||||
|
||||
// Update to non-urgent
|
||||
const updatedBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<TrialBanner business={updatedBusiness} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should now be non-urgent
|
||||
expect(container.querySelector('.from-blue-600')).toBeInTheDocument();
|
||||
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle business without name gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
name: '',
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should still render the banner
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle switching from active to inactive trial', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5,
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
|
||||
// Update to inactive
|
||||
const updatedBusiness = createMockBusiness({
|
||||
isTrialActive: false,
|
||||
daysLeftInTrial: 5,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<TrialBanner business={updatedBusiness} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should no longer render
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button Interactions', () => {
|
||||
it('should prevent multiple rapid clicks on upgrade button', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(upgradeButton);
|
||||
fireEvent.click(upgradeButton);
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
// Navigate should still only be called once per click (no debouncing in component)
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not interfere with other buttons after dismiss', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Banner is gone
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
|
||||
// Upgrade button should also be gone
|
||||
expect(screen.queryByRole('button', { name: /upgrade now/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should have shadow and proper background for visibility', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const banner = container.querySelector('.shadow-md');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have gradient background for visual appeal', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const gradient = container.querySelector('.bg-gradient-to-r');
|
||||
expect(gradient).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show hover states on interactive elements', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
expect(upgradeButton).toHaveClass('hover:bg-blue-50');
|
||||
});
|
||||
|
||||
it('should have appropriate spacing and padding', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Check for padding classes
|
||||
const contentContainer = container.querySelector('.py-3');
|
||||
expect(contentContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render icons with proper size', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Icons should have consistent size classes
|
||||
const iconContainer = container.querySelector('.rounded-full');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show different icons for urgent vs non-urgent states', () => {
|
||||
const nonUrgentBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container: container1, unmount } = renderWithRouter(
|
||||
<TrialBanner business={nonUrgentBusiness} />
|
||||
);
|
||||
|
||||
// Non-urgent should not have pulse animation
|
||||
expect(container1.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
const urgentBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 2,
|
||||
});
|
||||
|
||||
const { container: container2 } = renderWithRouter(
|
||||
<TrialBanner business={urgentBusiness} />
|
||||
);
|
||||
|
||||
// Urgent should have pulse animation
|
||||
expect(container2.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
567
frontend/src/components/__tests__/UpgradePrompt.test.tsx
Normal file
567
frontend/src/components/__tests__/UpgradePrompt.test.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Unit tests for UpgradePrompt, LockedSection, and LockedButton components
|
||||
*
|
||||
* Tests upgrade prompts that appear when features are not available in the current plan.
|
||||
* Covers:
|
||||
* - Different variants (inline, banner, overlay)
|
||||
* - Different sizes (sm, md, lg)
|
||||
* - Feature names and descriptions
|
||||
* - Navigation to billing page
|
||||
* - LockedSection wrapper behavior
|
||||
* - LockedButton disabled state and tooltip
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import {
|
||||
UpgradePrompt,
|
||||
LockedSection,
|
||||
LockedButton,
|
||||
} from '../UpgradePrompt';
|
||||
import { FeatureKey } from '../../hooks/usePlanFeatures';
|
||||
|
||||
// Mock react-router-dom's Link component
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
Link: ({ to, children, className, ...props }: any) => (
|
||||
<a href={to} className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Wrapper component that provides router context
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('UpgradePrompt', () => {
|
||||
describe('Inline Variant', () => {
|
||||
it('should render inline upgrade prompt with lock icon', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="inline" />);
|
||||
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
|
||||
// Check for styling classes
|
||||
const container = screen.getByText('Upgrade Required').parentElement;
|
||||
expect(container).toHaveClass('bg-amber-50', 'text-amber-700');
|
||||
});
|
||||
|
||||
it('should render small badge style for inline variant', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="webhooks" variant="inline" />
|
||||
);
|
||||
|
||||
const badge = container.querySelector('.bg-amber-50');
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveClass('text-xs', 'rounded-md');
|
||||
});
|
||||
|
||||
it('should not show description or upgrade button in inline variant', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="api_access" variant="inline" />);
|
||||
|
||||
expect(screen.queryByText(/integrate with external/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render for any feature in inline mode', () => {
|
||||
const features: FeatureKey[] = ['plugins', 'custom_domain', 'white_label'];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const { unmount } = renderWithRouter(
|
||||
<UpgradePrompt feature={feature} variant="inline" />
|
||||
);
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Banner Variant', () => {
|
||||
it('should render banner with feature name and crown icon', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
|
||||
|
||||
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render feature description by default', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/send automated sms reminders to customers and staff/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide description when showDescription is false', () => {
|
||||
renderWithRouter(
|
||||
<UpgradePrompt
|
||||
feature="sms_reminders"
|
||||
variant="banner"
|
||||
showDescription={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(/send automated sms reminders/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render upgrade button linking to billing settings', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="webhooks" variant="banner" />);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
|
||||
expect(upgradeLink).toBeInTheDocument();
|
||||
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
|
||||
});
|
||||
|
||||
it('should have gradient styling for banner variant', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="api_access" variant="banner" />
|
||||
);
|
||||
|
||||
const banner = container.querySelector('.bg-gradient-to-br.from-amber-50');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(banner).toHaveClass('border-2', 'border-amber-300');
|
||||
});
|
||||
|
||||
it('should render crown icon in banner', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="custom_domain" variant="banner" />);
|
||||
|
||||
// Crown icon should be in the button text
|
||||
const upgradeButton = screen.getByRole('link', { name: /upgrade your plan/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all feature names correctly', () => {
|
||||
const features: FeatureKey[] = [
|
||||
'webhooks',
|
||||
'api_access',
|
||||
'custom_domain',
|
||||
'white_label',
|
||||
'plugins',
|
||||
];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const { unmount } = renderWithRouter(
|
||||
<UpgradePrompt feature={feature} variant="banner" />
|
||||
);
|
||||
// Feature name should be in the heading
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlay Variant', () => {
|
||||
it('should render overlay with blurred children', () => {
|
||||
renderWithRouter(
|
||||
<UpgradePrompt feature="sms_reminders" variant="overlay">
|
||||
<div data-testid="locked-content">Locked Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const lockedContent = screen.getByTestId('locked-content');
|
||||
expect(lockedContent).toBeInTheDocument();
|
||||
|
||||
// Check that parent has blur styling
|
||||
const parent = lockedContent.parentElement;
|
||||
expect(parent).toHaveClass('blur-sm', 'opacity-50');
|
||||
});
|
||||
|
||||
it('should render feature name and description in overlay', () => {
|
||||
renderWithRouter(
|
||||
<UpgradePrompt feature="webhooks" variant="overlay">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Webhooks')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/integrate with external services using webhooks/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render lock icon in overlay', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="api_access" variant="overlay">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
// Lock icon should be in a rounded circle
|
||||
const iconCircle = container.querySelector('.rounded-full.bg-gradient-to-br');
|
||||
expect(iconCircle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render upgrade button in overlay', () => {
|
||||
renderWithRouter(
|
||||
<UpgradePrompt feature="custom_domain" variant="overlay">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
|
||||
expect(upgradeLink).toBeInTheDocument();
|
||||
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
|
||||
});
|
||||
|
||||
it('should apply small size styling', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="plugins" variant="overlay" size="sm">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const overlayContent = container.querySelector('.p-4');
|
||||
expect(overlayContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply medium size styling by default', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="plugins" variant="overlay">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const overlayContent = container.querySelector('.p-6');
|
||||
expect(overlayContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply large size styling', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="plugins" variant="overlay" size="lg">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const overlayContent = container.querySelector('.p-8');
|
||||
expect(overlayContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make children non-interactive', () => {
|
||||
renderWithRouter(
|
||||
<UpgradePrompt feature="white_label" variant="overlay">
|
||||
<button data-testid="locked-button">Click Me</button>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('locked-button');
|
||||
const parent = button.parentElement;
|
||||
expect(parent).toHaveClass('pointer-events-none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Behavior', () => {
|
||||
it('should default to banner variant when no variant specified', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="sms_reminders" />);
|
||||
|
||||
// Banner should show feature name in heading
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show description by default', () => {
|
||||
renderWithRouter(<UpgradePrompt feature="webhooks" />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/integrate with external services/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use medium size by default', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<UpgradePrompt feature="plugins" variant="overlay">
|
||||
<div>Content</div>
|
||||
</UpgradePrompt>
|
||||
);
|
||||
|
||||
const overlayContent = container.querySelector('.p-6');
|
||||
expect(overlayContent).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LockedSection', () => {
|
||||
describe('Unlocked State', () => {
|
||||
it('should render children when not locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="sms_reminders" isLocked={false}>
|
||||
<div data-testid="content">Available Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Available Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show upgrade prompt when unlocked', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="webhooks" isLocked={false}>
|
||||
<div>Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked State', () => {
|
||||
it('should show banner prompt by default when locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="sms_reminders" isLocked={true}>
|
||||
<div>Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show overlay prompt when variant is overlay', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="api_access" isLocked={true} variant="overlay">
|
||||
<div data-testid="locked-content">Locked Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('locked-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show fallback content instead of upgrade prompt when provided', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection
|
||||
feature="custom_domain"
|
||||
isLocked={true}
|
||||
fallback={<div data-testid="fallback">Custom Fallback</div>}
|
||||
>
|
||||
<div>Original Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('fallback')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Fallback')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render original children when locked without overlay', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="webhooks" isLocked={true} variant="banner">
|
||||
<div data-testid="original">Original Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('original')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/webhooks.*upgrade required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render blurred children with overlay variant', () => {
|
||||
renderWithRouter(
|
||||
<LockedSection feature="plugins" isLocked={true} variant="overlay">
|
||||
<div data-testid="blurred-content">Blurred Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('blurred-content');
|
||||
expect(content).toBeInTheDocument();
|
||||
expect(content.parentElement).toHaveClass('blur-sm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different Features', () => {
|
||||
it('should work with different feature keys', () => {
|
||||
const features: FeatureKey[] = [
|
||||
'white_label',
|
||||
'custom_oauth',
|
||||
'can_create_plugins',
|
||||
'tasks',
|
||||
];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const { unmount } = renderWithRouter(
|
||||
<LockedSection feature={feature} isLocked={true}>
|
||||
<div>Content</div>
|
||||
</LockedSection>
|
||||
);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LockedButton', () => {
|
||||
describe('Unlocked State', () => {
|
||||
it('should render normal clickable button when not locked', () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithRouter(
|
||||
<LockedButton
|
||||
feature="sms_reminders"
|
||||
isLocked={false}
|
||||
onClick={handleClick}
|
||||
className="custom-class"
|
||||
>
|
||||
Click Me
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /click me/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveClass('custom-class');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not show lock icon when unlocked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="webhooks" isLocked={false}>
|
||||
Submit
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /submit/i });
|
||||
expect(button.querySelector('svg')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locked State', () => {
|
||||
it('should render disabled button with lock icon when locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="api_access" isLocked={true}>
|
||||
Submit
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /submit/i });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass('opacity-50', 'cursor-not-allowed');
|
||||
});
|
||||
|
||||
it('should display lock icon when locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="custom_domain" isLocked={true}>
|
||||
Save
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.textContent).toContain('Save');
|
||||
});
|
||||
|
||||
it('should show tooltip on hover when locked', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<LockedButton feature="plugins" isLocked={true}>
|
||||
Create Plugin
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
// Tooltip should exist in DOM
|
||||
const tooltip = container.querySelector('.opacity-0');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(tooltip?.textContent).toContain('Upgrade Required');
|
||||
});
|
||||
|
||||
it('should not trigger onClick when locked', () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithRouter(
|
||||
<LockedButton
|
||||
feature="white_label"
|
||||
isLocked={true}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Click Me
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom className even when locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton
|
||||
feature="webhooks"
|
||||
isLocked={true}
|
||||
className="custom-btn"
|
||||
>
|
||||
Submit
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('custom-btn');
|
||||
});
|
||||
|
||||
it('should display feature name in tooltip', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<LockedButton feature="sms_reminders" isLocked={true}>
|
||||
Send SMS
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const tooltip = container.querySelector('.whitespace-nowrap');
|
||||
expect(tooltip?.textContent).toContain('SMS Reminders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different Features', () => {
|
||||
it('should work with various feature keys', () => {
|
||||
const features: FeatureKey[] = [
|
||||
'export_data',
|
||||
'video_conferencing',
|
||||
'two_factor_auth',
|
||||
'masked_calling',
|
||||
];
|
||||
|
||||
features.forEach((feature) => {
|
||||
const { unmount } = renderWithRouter(
|
||||
<LockedButton feature={feature} isLocked={true}>
|
||||
Action
|
||||
</LockedButton>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper button role when unlocked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="plugins" isLocked={false}>
|
||||
Save
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper button role when locked', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="webhooks" isLocked={true}>
|
||||
Submit
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should indicate disabled state for screen readers', () => {
|
||||
renderWithRouter(
|
||||
<LockedButton feature="api_access" isLocked={true}>
|
||||
Create
|
||||
</LockedButton>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
361
frontend/src/components/booking/AuthSection.tsx
Normal file
361
frontend/src/components/booking/AuthSection.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Lock, User as UserIcon, ArrowRight, Shield } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../../api/client';
|
||||
|
||||
export interface User {
|
||||
id: string | number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AuthSectionProps {
|
||||
onLogin: (user: User) => void;
|
||||
}
|
||||
|
||||
export const AuthSection: React.FC<AuthSectionProps> = ({ onLogin }) => {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Email verification states
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [verifyingCode, setVerifyingCode] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login/', {
|
||||
username: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
const user: User = {
|
||||
id: response.data.user.id,
|
||||
email: response.data.user.email,
|
||||
name: response.data.user.full_name || response.data.user.email,
|
||||
};
|
||||
|
||||
toast.success('Welcome back!');
|
||||
onLogin(user);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Send verification email
|
||||
await api.post('/auth/send-verification/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
});
|
||||
|
||||
toast.success('Verification code sent to your email!');
|
||||
setNeedsVerification(true);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Failed to send verification code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setVerifyingCode(true);
|
||||
|
||||
try {
|
||||
// Verify code and create account
|
||||
const response = await api.post('/auth/verify-and-register/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
password: password,
|
||||
verification_code: verificationCode
|
||||
});
|
||||
|
||||
const user: User = {
|
||||
id: response.data.user.id,
|
||||
email: response.data.user.email,
|
||||
name: response.data.user.full_name || response.data.user.name,
|
||||
};
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
onLogin(user);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.detail || 'Verification failed');
|
||||
} finally {
|
||||
setVerifyingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post('/auth/send-verification/', {
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName
|
||||
});
|
||||
toast.success('New code sent!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to resend code');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
if (isLogin) {
|
||||
handleLogin(e);
|
||||
} else {
|
||||
handleSignup(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Show verification step for new customers
|
||||
if (needsVerification && !isLogin) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
We've sent a 6-digit code to <span className="font-medium text-gray-900 dark:text-white">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<form onSubmit={handleVerifyCode} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="block w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifyingCode || verificationCode.length !== 6}
|
||||
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{verifyingCode ? (
|
||||
<span className="animate-pulse">Verifying...</span>
|
||||
) : (
|
||||
<>
|
||||
Verify & Continue
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResendCode}
|
||||
disabled={loading}
|
||||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 disabled:opacity-50"
|
||||
>
|
||||
Resend Code
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNeedsVerification(false);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Change email address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{isLogin ? 'Welcome Back' : 'Create Account'}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
{isLogin
|
||||
? 'Sign in to access your bookings and history.'
|
||||
: 'Join us to book your first premium service.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{!isLogin && (
|
||||
<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">First Name</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required={!isLogin}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required={!isLogin}
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="block w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={isLogin ? undefined : 8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{!isLogin && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={`block w-full pl-10 pr-3 py-2.5 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors ${
|
||||
confirmPassword && password !== confirmPassword
|
||||
? 'border-red-300 dark:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{confirmPassword && password !== confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Processing...</span>
|
||||
) : (
|
||||
<>
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLogin(!isLogin);
|
||||
setConfirmPassword('');
|
||||
setFirstName('');
|
||||
setLastName('');
|
||||
}}
|
||||
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
frontend/src/components/booking/BookingWidget.tsx
Normal file
69
frontend/src/components/booking/BookingWidget.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePublicServices, useCreateBooking } from '../../hooks/useBooking';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface BookingWidgetProps {
|
||||
headline?: string;
|
||||
subheading?: string;
|
||||
accentColor?: string;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export const BookingWidget: React.FC<BookingWidgetProps> = ({
|
||||
headline = "Book Appointment",
|
||||
subheading = "Select a service",
|
||||
accentColor = "#2563eb",
|
||||
buttonLabel = "Book Now"
|
||||
}) => {
|
||||
const { data: services, isLoading } = usePublicServices();
|
||||
const createBooking = useCreateBooking();
|
||||
const [selectedService, setSelectedService] = useState<any>(null);
|
||||
|
||||
if (isLoading) return <div className="flex justify-center"><Loader2 className="animate-spin" /></div>;
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!selectedService) return;
|
||||
try {
|
||||
await createBooking.mutateAsync({ service_id: selectedService.id });
|
||||
alert("Booking created (stub)!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error creating booking");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="booking-widget p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-900/30 max-w-md mx-auto text-left border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{services?.length === 0 && <p className="text-gray-600 dark:text-gray-400">No services available.</p>}
|
||||
{services?.map((service: any) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedService?.id === service.id
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 dark:border-indigo-400'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-700/50'
|
||||
}`}
|
||||
onClick={() => setSelectedService(service)}
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={!selectedService}
|
||||
className="w-full py-3 px-4 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white font-semibold disabled:opacity-50 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingWidget;
|
||||
113
frontend/src/components/booking/Confirmation.tsx
Normal file
113
frontend/src/components/booking/Confirmation.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle, Calendar, MapPin, ArrowRight } from 'lucide-react';
|
||||
import { PublicService } from '../../hooks/useBooking';
|
||||
import { User } from './AuthSection';
|
||||
|
||||
interface BookingState {
|
||||
step: number;
|
||||
service: PublicService | null;
|
||||
date: Date | null;
|
||||
timeSlot: string | null;
|
||||
user: User | null;
|
||||
paymentMethod: string | null;
|
||||
}
|
||||
|
||||
interface ConfirmationProps {
|
||||
booking: BookingState;
|
||||
}
|
||||
|
||||
export const Confirmation: React.FC<ConfirmationProps> = ({ booking }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!booking.service || !booking.date || !booking.timeSlot) return null;
|
||||
|
||||
// Generate a pseudo-random booking reference based on timestamp
|
||||
const bookingRef = `BK-${Date.now().toString().slice(-6)}`;
|
||||
|
||||
return (
|
||||
<div className="text-center max-w-2xl mx-auto py-10">
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="h-24 w-24 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Booking Confirmed!</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
|
||||
Thank you, {booking.user?.name}. Your appointment has been successfully scheduled.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden text-left">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Booking Details</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Ref: #{bookingRef}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start">
|
||||
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center flex-shrink-0 mr-4">
|
||||
{booking.service.photos && booking.service.photos.length > 0 ? (
|
||||
<img src={booking.service.photos[0]} className="h-12 w-12 rounded-lg object-cover" alt="" />
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-lg bg-indigo-200 dark:bg-indigo-800" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{booking.service.name}</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{booking.service.duration} minutes</p>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<p className="font-medium text-gray-900 dark:text-white">${(booking.service.price_cents / 100).toFixed(2)}</p>
|
||||
{booking.service.deposit_amount_cents && booking.service.deposit_amount_cents > 0 && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Deposit Paid</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex flex-col sm:flex-row sm:justify-between gap-4">
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<Calendar className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Date & Time</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{booking.date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} at {booking.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<MapPin className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Location</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">See confirmation email</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
A confirmation email has been sent to {booking.user?.email}.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex justify-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors shadow-lg"
|
||||
>
|
||||
Done
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Clear booking state and start fresh
|
||||
sessionStorage.removeItem('booking_state');
|
||||
navigate('/book');
|
||||
}}
|
||||
className="px-6 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Book Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
276
frontend/src/components/booking/DateTimeSelection.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
|
||||
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
|
||||
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
|
||||
|
||||
interface DateTimeSelectionProps {
|
||||
serviceId?: number;
|
||||
selectedDate: Date | null;
|
||||
selectedTimeSlot: string | null;
|
||||
onDateChange: (date: Date) => void;
|
||||
onTimeChange: (time: string) => void;
|
||||
}
|
||||
|
||||
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
|
||||
serviceId,
|
||||
selectedDate,
|
||||
selectedTimeSlot,
|
||||
onDateChange,
|
||||
onTimeChange
|
||||
}) => {
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
|
||||
|
||||
// Calculate date range for business hours query (current month view)
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const start = new Date(currentYear, currentMonth, 1);
|
||||
const end = new Date(currentYear, currentMonth + 1, 0);
|
||||
return {
|
||||
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
|
||||
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||
};
|
||||
}, [currentMonth, currentYear]);
|
||||
|
||||
// Fetch business hours for the month
|
||||
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
|
||||
|
||||
// Create a map of dates to their open status
|
||||
const openDaysMap = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
if (businessHours?.dates) {
|
||||
businessHours.dates.forEach(day => {
|
||||
map.set(day.date, day.is_open);
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [businessHours]);
|
||||
|
||||
// Format selected date for API query (YYYY-MM-DD)
|
||||
const dateString = selectedDate
|
||||
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
|
||||
: undefined;
|
||||
|
||||
// Fetch availability when both serviceId and date are set
|
||||
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
|
||||
|
||||
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
||||
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return selectedDate?.getDate() === day &&
|
||||
selectedDate?.getMonth() === currentMonth &&
|
||||
selectedDate?.getFullYear() === currentYear;
|
||||
};
|
||||
|
||||
const isPast = (day: number) => {
|
||||
const d = new Date(currentYear, currentMonth, day);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
return d < now;
|
||||
};
|
||||
|
||||
const isClosed = (day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
// If we have business hours data, use it. Otherwise default to open (except past dates)
|
||||
if (openDaysMap.size > 0) {
|
||||
return openDaysMap.get(dateStr) === false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
return isPast(day) || isClosed(day);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Calendar Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
||||
Select Date
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
|
||||
{monthName} {currentYear}
|
||||
</span>
|
||||
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
|
||||
</div>
|
||||
|
||||
{businessHoursLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const past = isPast(day);
|
||||
const closed = isClosed(day);
|
||||
const disabled = isDisabled(day);
|
||||
const selected = isSelected(day);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const newDate = new Date(currentYear, currentMonth, day);
|
||||
onDateChange(newDate);
|
||||
}}
|
||||
className={`
|
||||
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
|
||||
${selected
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
|
||||
: closed
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: past
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
|
||||
<span>Closed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Slots Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
|
||||
{!selectedDate ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a date first
|
||||
</div>
|
||||
) : availabilityLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
|
||||
<XCircle className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium">Failed to load availability</p>
|
||||
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
|
||||
{error instanceof Error ? error.message : 'Please try again'}
|
||||
</p>
|
||||
</div>
|
||||
) : availability?.is_open === false ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
|
||||
<p className="font-medium">Business Closed</p>
|
||||
<p className="text-sm mt-1">Please select another date</p>
|
||||
</div>
|
||||
) : availability?.slots && availability.slots.length > 0 ? (
|
||||
<>
|
||||
{(() => {
|
||||
// Determine which timezone to display based on business settings
|
||||
const displayTimezone = availability.timezone_display_mode === 'viewer'
|
||||
? getUserTimezone()
|
||||
: availability.business_timezone || getUserTimezone();
|
||||
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
{availability.business_hours && (
|
||||
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} • </>
|
||||
)}
|
||||
Times shown in {tzAbbrev}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{availability.slots.map((slot) => {
|
||||
// Format time in the appropriate timezone
|
||||
const displayTime = formatTimeForDisplay(
|
||||
slot.time,
|
||||
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slot.time}
|
||||
disabled={!slot.available}
|
||||
onClick={() => onTimeChange(displayTime)}
|
||||
className={`
|
||||
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
|
||||
${!slot.available
|
||||
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
|
||||
: selectedTimeSlot === displayTime
|
||||
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{displayTime}
|
||||
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : !serviceId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
Please select a service first
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
|
||||
No available time slots for this date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
134
frontend/src/components/booking/GeminiChat.tsx
Normal file
134
frontend/src/components/booking/GeminiChat.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
|
||||
import { BookingState, ChatMessage } from './types';
|
||||
// TODO: Implement Gemini service
|
||||
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
|
||||
// Mock implementation - replace with actual Gemini API call
|
||||
return "I'm here to help you book your appointment. Please use the booking form above.";
|
||||
};
|
||||
|
||||
interface GeminiChatProps {
|
||||
currentBookingState: BookingState;
|
||||
}
|
||||
|
||||
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
|
||||
]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', text: inputText };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
|
||||
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
|
||||
} catch (error) {
|
||||
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
{/* Chat Window */}
|
||||
{isOpen && (
|
||||
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
||||
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="font-semibold">Lumina Assistant</span>
|
||||
</div>
|
||||
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
max-w-[80%] px-4 py-2 rounded-2xl text-sm
|
||||
${msg.role === 'user'
|
||||
? 'bg-indigo-600 text-white rounded-br-none'
|
||||
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white border-t border-gray-100">
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Ask about services..."
|
||||
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !inputText.trim()}
|
||||
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`
|
||||
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
|
||||
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
|
||||
`}
|
||||
style={{display: isOpen ? 'none' : 'flex'}}
|
||||
>
|
||||
<MessageCircle className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
frontend/src/components/booking/PaymentSection.tsx
Normal file
159
frontend/src/components/booking/PaymentSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PublicService } from '../../hooks/useBooking';
|
||||
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
|
||||
|
||||
interface PaymentSectionProps {
|
||||
service: PublicService;
|
||||
onPaymentComplete: () => void;
|
||||
}
|
||||
|
||||
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [cardNumber, setCardNumber] = useState('');
|
||||
const [expiry, setExpiry] = useState('');
|
||||
const [cvc, setCvc] = useState('');
|
||||
|
||||
// Convert cents to dollars
|
||||
const price = service.price_cents / 100;
|
||||
const deposit = (service.deposit_amount_cents || 0) / 100;
|
||||
|
||||
// Auto-format card number
|
||||
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let val = e.target.value.replace(/\D/g, '');
|
||||
val = val.substring(0, 16);
|
||||
val = val.replace(/(\d{4})/g, '$1 ').trim();
|
||||
setCardNumber(val);
|
||||
};
|
||||
|
||||
const handlePayment = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProcessing(true);
|
||||
|
||||
// Simulate Stripe Payment Intent & Processing
|
||||
setTimeout(() => {
|
||||
setProcessing(false);
|
||||
onPaymentComplete();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Payment Details Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
|
||||
Card Details
|
||||
</h3>
|
||||
<div className="flex space-x-2">
|
||||
{/* Mock Card Icons */}
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={cardNumber}
|
||||
onChange={handleCardInput}
|
||||
placeholder="0000 0000 0000 0000"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(e.target.value)}
|
||||
placeholder="MM / YY"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={cvc}
|
||||
onChange={(e) => setCvc(e.target.value)}
|
||||
placeholder="123"
|
||||
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
|
||||
/>
|
||||
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
|
||||
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<p className="text-sm text-indigo-800 dark:text-indigo-200">
|
||||
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Column */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Service Total</span>
|
||||
<span>${price.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Tax (Estimated)</span>
|
||||
<span>$0.00</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
|
||||
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
|
||||
<span>Total</span>
|
||||
<span>${price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deposit > 0 ? (
|
||||
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
|
||||
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Due at appointment</span>
|
||||
<span>${(price - deposit).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
|
||||
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="payment-form"
|
||||
disabled={processing}
|
||||
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
114
frontend/src/components/booking/ServiceSelection.tsx
Normal file
114
frontend/src/components/booking/ServiceSelection.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Clock, DollarSign, Loader2 } from 'lucide-react';
|
||||
import { usePublicServices, usePublicBusinessInfo, PublicService } from '../../hooks/useBooking';
|
||||
|
||||
interface ServiceSelectionProps {
|
||||
selectedService: PublicService | null;
|
||||
onSelect: (service: PublicService) => void;
|
||||
}
|
||||
|
||||
export const ServiceSelection: React.FC<ServiceSelectionProps> = ({ selectedService, onSelect }) => {
|
||||
const { data: services, isLoading: servicesLoading } = usePublicServices();
|
||||
const { data: businessInfo, isLoading: businessLoading } = usePublicBusinessInfo();
|
||||
|
||||
const isLoading = servicesLoading || businessLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const heading = businessInfo?.service_selection_heading || 'Choose your experience';
|
||||
const subheading = businessInfo?.service_selection_subheading || 'Select a service to begin your booking.';
|
||||
|
||||
// Get first photo as image, or use a placeholder
|
||||
const getServiceImage = (service: PublicService): string | null => {
|
||||
if (service.photos && service.photos.length > 0) {
|
||||
return service.photos[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Format price from cents to dollars
|
||||
const formatPrice = (cents: number): string => {
|
||||
return (cents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{heading}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">{subheading}</p>
|
||||
</div>
|
||||
|
||||
{(!services || services.length === 0) && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No services available at this time.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{services?.map((service) => {
|
||||
const image = getServiceImage(service);
|
||||
const hasImage = !!image;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
onClick={() => onSelect(service)}
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border-2 transition-all duration-200 cursor-pointer group
|
||||
${selectedService?.id === service.id
|
||||
? 'border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/20 ring-2 ring-indigo-600 dark:ring-indigo-400 ring-offset-2 dark:ring-offset-gray-900'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg bg-white dark:bg-gray-800'}
|
||||
`}
|
||||
>
|
||||
<div className="flex h-full min-h-[140px]">
|
||||
{hasImage && (
|
||||
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={service.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{service.name}
|
||||
</h3>
|
||||
{service.description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4 mr-1.5" />
|
||||
{service.duration} mins
|
||||
</div>
|
||||
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{formatPrice(service.price_cents)}
|
||||
</div>
|
||||
</div>
|
||||
{service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
|
||||
<div className="mt-2 text-xs text-indigo-600 dark:text-indigo-400 font-medium">
|
||||
Deposit required: ${formatPrice(service.deposit_amount_cents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/booking/Steps.tsx
Normal file
61
frontend/src/components/booking/Steps.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface StepsProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: 1, name: 'Service' },
|
||||
{ id: 2, name: 'Date & Time' },
|
||||
{ id: 3, name: 'Account' },
|
||||
{ id: 4, name: 'Payment' },
|
||||
{ id: 5, name: 'Done' },
|
||||
];
|
||||
|
||||
export const Steps: React.FC<StepsProps> = ({ currentStep }) => {
|
||||
return (
|
||||
<nav aria-label="Progress">
|
||||
<ol role="list" className="flex items-center">
|
||||
{steps.map((step, stepIdx) => (
|
||||
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pr-8 sm:pr-20' : ''} relative`}>
|
||||
{step.id < currentStep ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-indigo-600 dark:bg-indigo-500" />
|
||||
</div>
|
||||
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
|
||||
<Check className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
) : step.id === currentStep ? (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800" aria-current="step">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<a href="#" className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-gray-600" aria-hidden="true" />
|
||||
<span className="sr-only">{step.name}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 w-max text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{step.name}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
61
frontend/src/components/booking/constants.ts
Normal file
61
frontend/src/components/booking/constants.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Service, TimeSlot } from './types';
|
||||
|
||||
// Mock services for booking flow
|
||||
// TODO: In production, these should be fetched from the API
|
||||
export const SERVICES: Service[] = [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Rejuvenating Facial',
|
||||
description: 'A 60-minute deep cleansing and hydrating facial treatment.',
|
||||
durationMin: 60,
|
||||
price: 120,
|
||||
deposit: 30,
|
||||
category: 'Skincare',
|
||||
image: 'https://picsum.photos/400/300?random=1'
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
name: 'Deep Tissue Massage',
|
||||
description: 'Therapeutic massage focusing on realigning deeper layers of muscles.',
|
||||
durationMin: 90,
|
||||
price: 150,
|
||||
deposit: 50,
|
||||
category: 'Massage',
|
||||
image: 'https://picsum.photos/400/300?random=2'
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
name: 'Executive Haircut',
|
||||
description: 'Precision haircut with wash, style, and hot towel finish.',
|
||||
durationMin: 45,
|
||||
price: 65,
|
||||
deposit: 15,
|
||||
category: 'Hair',
|
||||
image: 'https://picsum.photos/400/300?random=3'
|
||||
},
|
||||
{
|
||||
id: 's4',
|
||||
name: 'Full Body Scrub',
|
||||
description: 'Exfoliating treatment to remove dead skin cells and improve circulation.',
|
||||
durationMin: 60,
|
||||
price: 110,
|
||||
deposit: 25,
|
||||
category: 'Body',
|
||||
image: 'https://picsum.photos/400/300?random=4'
|
||||
}
|
||||
];
|
||||
|
||||
// Mock time slots
|
||||
// TODO: In production, these should be fetched from the availability API
|
||||
export const TIME_SLOTS: TimeSlot[] = [
|
||||
{ id: 't1', time: '09:00 AM', available: true },
|
||||
{ id: 't2', time: '10:00 AM', available: true },
|
||||
{ id: 't3', time: '11:00 AM', available: false },
|
||||
{ id: 't4', time: '01:00 PM', available: true },
|
||||
{ id: 't5', time: '02:00 PM', available: true },
|
||||
{ id: 't6', time: '03:00 PM', available: true },
|
||||
{ id: 't7', time: '04:00 PM', available: false },
|
||||
{ id: 't8', time: '05:00 PM', available: true },
|
||||
];
|
||||
|
||||
export const APP_NAME = "SmoothSchedule";
|
||||
36
frontend/src/components/booking/types.ts
Normal file
36
frontend/src/components/booking/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
durationMin: number;
|
||||
price: number;
|
||||
deposit: number;
|
||||
image: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
id: string;
|
||||
time: string; // "09:00 AM"
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface BookingState {
|
||||
step: number;
|
||||
service: Service | null;
|
||||
date: Date | null;
|
||||
timeSlot: string | null;
|
||||
user: User | null;
|
||||
paymentMethod: string | null;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'model';
|
||||
text: string;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
|
||||
openTickets.slice(0, 5).map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to="/tickets"
|
||||
to="/dashboard/tickets"
|
||||
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -110,7 +110,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
|
||||
|
||||
{openTickets.length > 5 && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
to="/dashboard/tickets"
|
||||
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
|
||||
>
|
||||
View all {openTickets.length} tickets
|
||||
|
||||
@@ -841,8 +841,17 @@ describe('ChartWidget', () => {
|
||||
it('should support different color schemes', () => {
|
||||
const colors = ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6'];
|
||||
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
color={colors[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
colors.forEach((color) => {
|
||||
const { container, rerender } = render(
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
@@ -853,17 +862,6 @@ describe('ChartWidget', () => {
|
||||
|
||||
const bar = screen.getByTestId('bar');
|
||||
expect(bar).toHaveAttribute('data-fill', color);
|
||||
|
||||
if (color !== colors[colors.length - 1]) {
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
color={colors[colors.indexOf(color) + 1]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
242
frontend/src/components/marketing/DynamicPricingCards.tsx
Normal file
242
frontend/src/components/marketing/DynamicPricingCards.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
usePublicPlans,
|
||||
formatPrice,
|
||||
PublicPlanVersion,
|
||||
} from '../../hooks/usePublicPlans';
|
||||
|
||||
interface DynamicPricingCardsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DynamicPricingCards: React.FC<DynamicPricingCardsProps> = ({ className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: plans, isLoading, error } = usePublicPlans();
|
||||
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plans) {
|
||||
return (
|
||||
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
|
||||
{t('marketing.pricing.loadError', 'Unable to load pricing. Please try again later.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort plans by display_order
|
||||
const sortedPlans = [...plans].sort(
|
||||
(a, b) => a.plan.display_order - b.plan.display_order
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg inline-flex">
|
||||
<button
|
||||
onClick={() => setBillingPeriod('monthly')}
|
||||
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
billingPeriod === 'monthly'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('marketing.pricing.monthly', 'Monthly')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingPeriod('annual')}
|
||||
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
billingPeriod === 'annual'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t('marketing.pricing.annual', 'Annual')}
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-semibold">
|
||||
{t('marketing.pricing.savePercent', 'Save ~17%')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{sortedPlans.map((planVersion) => (
|
||||
<PlanCard
|
||||
key={planVersion.id}
|
||||
planVersion={planVersion}
|
||||
billingPeriod={billingPeriod}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlanCardProps {
|
||||
planVersion: PublicPlanVersion;
|
||||
billingPeriod: 'monthly' | 'annual';
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ planVersion, billingPeriod }) => {
|
||||
const { t } = useTranslation();
|
||||
const { plan, is_most_popular, show_price, marketing_features, trial_days } = planVersion;
|
||||
|
||||
const price =
|
||||
billingPeriod === 'annual'
|
||||
? planVersion.price_yearly_cents
|
||||
: planVersion.price_monthly_cents;
|
||||
|
||||
const isEnterprise = !show_price || plan.code === 'enterprise';
|
||||
const isFree = price === 0 && plan.code === 'free';
|
||||
|
||||
// Determine CTA
|
||||
const ctaLink = isEnterprise ? '/contact' : `/signup?plan=${plan.code}`;
|
||||
const ctaText = isEnterprise
|
||||
? t('marketing.pricing.contactSales', 'Contact Sales')
|
||||
: isFree
|
||||
? t('marketing.pricing.getStartedFree', 'Get Started Free')
|
||||
: t('marketing.pricing.startTrial', 'Start Free Trial');
|
||||
|
||||
if (is_most_popular) {
|
||||
return (
|
||||
<div className="relative flex flex-col p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20 transform lg:scale-105 z-10">
|
||||
{/* Most Popular Badge */}
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full whitespace-nowrap">
|
||||
{t('marketing.pricing.mostPopular', 'Most Popular')}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-white mb-1">{plan.name}</h3>
|
||||
<p className="text-brand-100 text-sm">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-4">
|
||||
{isEnterprise ? (
|
||||
<span className="text-3xl font-bold text-white">
|
||||
{t('marketing.pricing.custom', 'Custom')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
<span className="text-brand-200 ml-1 text-sm">
|
||||
{billingPeriod === 'annual'
|
||||
? t('marketing.pricing.perYear', '/year')
|
||||
: t('marketing.pricing.perMonth', '/month')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{trial_days > 0 && !isFree && (
|
||||
<div className="mt-1 text-xs text-brand-100">
|
||||
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
|
||||
days: trial_days,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="flex-1 space-y-2 mb-6">
|
||||
{marketing_features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<Check className="h-4 w-4 text-brand-200 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-white text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
to={ctaLink}
|
||||
className="block w-full py-3 px-4 text-center text-sm font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
{ctaText}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-4">
|
||||
{isEnterprise ? (
|
||||
<span className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('marketing.pricing.custom', 'Custom')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-1 text-sm">
|
||||
{billingPeriod === 'annual'
|
||||
? t('marketing.pricing.perYear', '/year')
|
||||
: t('marketing.pricing.perMonth', '/month')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{trial_days > 0 && !isFree && (
|
||||
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
|
||||
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
|
||||
days: trial_days,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isFree && (
|
||||
<div className="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
{t('marketing.pricing.freeForever', 'Free forever')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="flex-1 space-y-2 mb-6">
|
||||
{marketing_features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<Check className="h-4 w-4 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-gray-700 dark:text-gray-300 text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<Link
|
||||
to={ctaLink}
|
||||
className={`block w-full py-3 px-4 text-center text-sm font-semibold rounded-xl transition-colors ${
|
||||
isFree
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 hover:bg-brand-100 dark:hover:bg-brand-900/50'
|
||||
}`}
|
||||
>
|
||||
{ctaText}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicPricingCards;
|
||||
251
frontend/src/components/marketing/FeatureComparisonTable.tsx
Normal file
251
frontend/src/components/marketing/FeatureComparisonTable.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React from 'react';
|
||||
import { Check, X, Minus, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
usePublicPlans,
|
||||
PublicPlanVersion,
|
||||
getPlanFeatureValue,
|
||||
formatLimit,
|
||||
} from '../../hooks/usePublicPlans';
|
||||
|
||||
// Feature categories for the comparison table
|
||||
const FEATURE_CATEGORIES = [
|
||||
{
|
||||
key: 'limits',
|
||||
features: [
|
||||
{ code: 'max_users', label: 'Team members' },
|
||||
{ code: 'max_resources', label: 'Resources' },
|
||||
{ code: 'max_locations', label: 'Locations' },
|
||||
{ code: 'max_services', label: 'Services' },
|
||||
{ code: 'max_customers', label: 'Customers' },
|
||||
{ code: 'max_appointments_per_month', label: 'Appointments/month' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'communication',
|
||||
features: [
|
||||
{ code: 'email_enabled', label: 'Email notifications' },
|
||||
{ code: 'max_email_per_month', label: 'Emails/month' },
|
||||
{ code: 'sms_enabled', label: 'SMS reminders' },
|
||||
{ code: 'max_sms_per_month', label: 'SMS/month' },
|
||||
{ code: 'masked_calling_enabled', label: 'Masked calling' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'booking',
|
||||
features: [
|
||||
{ code: 'online_booking', label: 'Online booking' },
|
||||
{ code: 'recurring_appointments', label: 'Recurring appointments' },
|
||||
{ code: 'payment_processing', label: 'Accept payments' },
|
||||
{ code: 'mobile_app_access', label: 'Mobile app' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'integrations',
|
||||
features: [
|
||||
{ code: 'integrations_enabled', label: 'Third-party integrations' },
|
||||
{ code: 'api_access', label: 'API access' },
|
||||
{ code: 'max_api_calls_per_day', label: 'API calls/day' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'branding',
|
||||
features: [
|
||||
{ code: 'custom_domain', label: 'Custom domain' },
|
||||
{ code: 'custom_branding', label: 'Custom branding' },
|
||||
{ code: 'remove_branding', label: 'Remove "Powered by"' },
|
||||
{ code: 'white_label', label: 'White label' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'enterprise',
|
||||
features: [
|
||||
{ code: 'multi_location', label: 'Multi-location management' },
|
||||
{ code: 'team_permissions', label: 'Team permissions' },
|
||||
{ code: 'audit_logs', label: 'Audit logs' },
|
||||
{ code: 'advanced_reporting', label: 'Advanced analytics' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'support',
|
||||
features: [
|
||||
{ code: 'priority_support', label: 'Priority support' },
|
||||
{ code: 'dedicated_account_manager', label: 'Dedicated account manager' },
|
||||
{ code: 'sla_guarantee', label: 'SLA guarantee' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
features: [
|
||||
{ code: 'max_storage_mb', label: 'File storage' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureComparisonTableProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: plans, isLoading, error } = usePublicPlans();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plans || plans.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort plans by display_order
|
||||
const sortedPlans = [...plans].sort(
|
||||
(a, b) => a.plan.display_order - b.plan.display_order
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`overflow-x-auto ${className}`}>
|
||||
<table className="w-full min-w-[800px]">
|
||||
{/* Header */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left py-4 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 w-64">
|
||||
{t('marketing.pricing.featureComparison.features', 'Features')}
|
||||
</th>
|
||||
{sortedPlans.map((planVersion) => (
|
||||
<th
|
||||
key={planVersion.id}
|
||||
className={`text-center py-4 px-4 text-sm font-semibold border-b border-gray-200 dark:border-gray-700 ${
|
||||
planVersion.is_most_popular
|
||||
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{planVersion.plan.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURE_CATEGORIES.map((category) => (
|
||||
<React.Fragment key={category.key}>
|
||||
{/* Category Header */}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={sortedPlans.length + 1}
|
||||
className="py-3 px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
{t(
|
||||
`marketing.pricing.featureComparison.categories.${category.key}`,
|
||||
category.key.charAt(0).toUpperCase() + category.key.slice(1)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{/* Features */}
|
||||
{category.features.map((feature) => (
|
||||
<tr
|
||||
key={feature.code}
|
||||
className="border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<td className="py-3 px-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
`marketing.pricing.featureComparison.features.${feature.code}`,
|
||||
feature.label
|
||||
)}
|
||||
</td>
|
||||
{sortedPlans.map((planVersion) => (
|
||||
<td
|
||||
key={`${planVersion.id}-${feature.code}`}
|
||||
className={`py-3 px-4 text-center ${
|
||||
planVersion.is_most_popular
|
||||
? 'bg-brand-50/50 dark:bg-brand-900/10'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<FeatureValue
|
||||
planVersion={planVersion}
|
||||
featureCode={feature.code}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeatureValueProps {
|
||||
planVersion: PublicPlanVersion;
|
||||
featureCode: string;
|
||||
}
|
||||
|
||||
const FeatureValue: React.FC<FeatureValueProps> = ({
|
||||
planVersion,
|
||||
featureCode,
|
||||
}) => {
|
||||
const value = getPlanFeatureValue(planVersion, featureCode);
|
||||
|
||||
// Handle null/undefined - feature not set
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean feature
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? (
|
||||
<Check className="w-5 h-5 text-green-500 mx-auto" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
);
|
||||
}
|
||||
|
||||
// Integer feature (limit)
|
||||
if (typeof value === 'number') {
|
||||
// Special handling for storage (convert MB to GB if > 1000)
|
||||
if (featureCode === 'max_storage_mb') {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Unlimited
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{(value / 1000).toFixed(0)} GB
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{value} MB
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular limit display
|
||||
return (
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatLimit(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <Minus className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />;
|
||||
};
|
||||
|
||||
export default FeatureComparisonTable;
|
||||
@@ -139,7 +139,7 @@ describe('CodeBlock', () => {
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts to copy icon after 2 seconds', () => {
|
||||
it('reverts to copy icon after 2 seconds', async () => {
|
||||
const code = 'test code';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
@@ -148,14 +148,18 @@ describe('CodeBlock', () => {
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
|
||||
// Click to copy
|
||||
fireEvent.click(copyButton);
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButton);
|
||||
});
|
||||
|
||||
// Should show Check icon
|
||||
let checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
|
||||
// Fast-forward 2 seconds using act to wrap state updates
|
||||
vi.advanceTimersByTime(2000);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// Should revert to Copy icon (check icon should be gone)
|
||||
checkIcon = container.querySelector('.text-green-400');
|
||||
|
||||
@@ -435,7 +435,9 @@ describe('Navbar', () => {
|
||||
});
|
||||
|
||||
it('should close mobile menu on route change', () => {
|
||||
// Test that mobile menu state resets when component receives new location
|
||||
// Test that clicking a navigation link closes the mobile menu
|
||||
// In production, clicking a link triggers a route change which closes the menu via useEffect
|
||||
// In tests with MemoryRouter, the route change happens and the useEffect fires
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper('/'),
|
||||
});
|
||||
@@ -447,14 +449,12 @@ describe('Navbar', () => {
|
||||
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click a navigation link (simulates route change behavior)
|
||||
// Click a navigation link - this triggers navigation to /features
|
||||
// The useEffect with location.pathname dependency should close the menu
|
||||
const featuresLink = screen.getAllByRole('link', { name: 'Features' })[1]; // Mobile menu link
|
||||
fireEvent.click(featuresLink);
|
||||
|
||||
// The useEffect with location.pathname dependency should close the menu
|
||||
// In actual usage, clicking a link triggers navigation which changes location.pathname
|
||||
// For this test, we verify the menu can be manually closed
|
||||
fireEvent.click(menuButton);
|
||||
// After navigation, menu should be closed
|
||||
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ interface SidebarItemProps {
|
||||
exact?: boolean;
|
||||
disabled?: boolean;
|
||||
badge?: string | number;
|
||||
badgeElement?: React.ReactNode;
|
||||
variant?: 'default' | 'settings';
|
||||
locked?: boolean;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
exact = false,
|
||||
disabled = false,
|
||||
badge,
|
||||
badgeElement,
|
||||
variant = 'default',
|
||||
locked = false,
|
||||
}) => {
|
||||
@@ -97,8 +99,10 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
<div className={className} title={label}>
|
||||
<Icon size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span className="flex-1">{label}</span>}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
||||
{(badge || badgeElement) && !isCollapsed && (
|
||||
badgeElement || (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -113,10 +117,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
{locked && <Lock size={12} className="opacity-60" />}
|
||||
</span>
|
||||
)}
|
||||
{badge && !isCollapsed && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
</span>
|
||||
{(badge || badgeElement) && !isCollapsed && (
|
||||
badgeElement || (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
@@ -256,6 +262,7 @@ interface SettingsSidebarItemProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
locked?: boolean;
|
||||
badgeElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,6 +274,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
label,
|
||||
description,
|
||||
locked = false,
|
||||
badgeElement,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
|
||||
@@ -289,6 +297,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
|
||||
{locked && (
|
||||
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
{badgeElement}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">
|
||||
|
||||
312
frontend/src/components/platform/DynamicFeaturesEditor.tsx
Normal file
312
frontend/src/components/platform/DynamicFeaturesEditor.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* DynamicFeaturesEditor
|
||||
*
|
||||
* A dynamic component that loads features from the billing system API
|
||||
* and renders them as toggles/inputs for editing business permissions.
|
||||
*
|
||||
* This is the DYNAMIC version that gets features from the billing catalog,
|
||||
* which is the single source of truth. When you add a new feature to the
|
||||
* billing system, it automatically appears here.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Key, AlertCircle } from 'lucide-react';
|
||||
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
|
||||
|
||||
export interface DynamicFeaturesEditorProps {
|
||||
/**
|
||||
* Current feature values mapped by tenant_field_name
|
||||
* For booleans: { can_use_sms_reminders: true, can_api_access: false, ... }
|
||||
* For integers: { max_users: 10, max_resources: 5, ... }
|
||||
*/
|
||||
values: Record<string, boolean | number | null>;
|
||||
|
||||
/**
|
||||
* Callback when a feature value changes
|
||||
* @param fieldName - The tenant_field_name of the feature
|
||||
* @param value - The new value (boolean for toggles, number for limits)
|
||||
*/
|
||||
onChange: (fieldName: string, value: boolean | number | null) => void;
|
||||
|
||||
/**
|
||||
* Optional: Only show features in these categories
|
||||
*/
|
||||
categories?: BillingFeature['category'][];
|
||||
|
||||
/**
|
||||
* Optional: Only show boolean or integer features
|
||||
*/
|
||||
featureType?: 'boolean' | 'integer';
|
||||
|
||||
/**
|
||||
* Optional: Exclude features by code
|
||||
*/
|
||||
excludeCodes?: string[];
|
||||
|
||||
/**
|
||||
* Show section header (default: true)
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Custom header title
|
||||
*/
|
||||
headerTitle?: string;
|
||||
|
||||
/**
|
||||
* Show descriptions under labels (default: false)
|
||||
*/
|
||||
showDescriptions?: boolean;
|
||||
|
||||
/**
|
||||
* Number of columns (default: 3)
|
||||
*/
|
||||
columns?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
|
||||
values,
|
||||
onChange,
|
||||
categories,
|
||||
featureType,
|
||||
excludeCodes = [],
|
||||
showHeader = true,
|
||||
headerTitle = 'Features & Permissions',
|
||||
showDescriptions = false,
|
||||
columns = 3,
|
||||
}) => {
|
||||
const { data: features, isLoading, error } = useBillingFeatures();
|
||||
|
||||
// Debug logging
|
||||
console.log('[DynamicFeaturesEditor] Features:', features?.length, 'Loading:', isLoading, 'Error:', error);
|
||||
|
||||
// Filter and group features
|
||||
const groupedFeatures = useMemo(() => {
|
||||
if (!features) {
|
||||
console.log('[DynamicFeaturesEditor] No features data');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Filter features
|
||||
const filtered = features.filter(f => {
|
||||
if (excludeCodes.includes(f.code)) return false;
|
||||
if (categories && !categories.includes(f.category)) return false;
|
||||
if (featureType && f.feature_type !== featureType) return false;
|
||||
if (!f.is_overridable) return false; // Skip non-overridable features
|
||||
if (!f.tenant_field_name) return false; // Skip features without tenant field
|
||||
return true;
|
||||
});
|
||||
console.log('[DynamicFeaturesEditor] Filtered features:', filtered.length, 'featureType:', featureType);
|
||||
|
||||
// Group by category
|
||||
const groups: Record<string, BillingFeature[]> = {};
|
||||
for (const feature of filtered) {
|
||||
if (!groups[feature.category]) {
|
||||
groups[feature.category] = [];
|
||||
}
|
||||
groups[feature.category].push(feature);
|
||||
}
|
||||
|
||||
// Sort features within each category by display_order
|
||||
for (const category of Object.keys(groups)) {
|
||||
groups[category].sort((a, b) => a.display_order - b.display_order);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [features, categories, featureType, excludeCodes]);
|
||||
|
||||
// Sort categories by their order
|
||||
const sortedCategories = useMemo(() => {
|
||||
return Object.keys(groupedFeatures).sort(
|
||||
(a, b) => (FEATURE_CATEGORY_META[a as BillingFeature['category']]?.order ?? 99) -
|
||||
(FEATURE_CATEGORY_META[b as BillingFeature['category']]?.order ?? 99)
|
||||
) as BillingFeature['category'][];
|
||||
}, [groupedFeatures]);
|
||||
|
||||
// Check if a dependent feature should be disabled
|
||||
const isDependencyDisabled = (feature: BillingFeature): boolean => {
|
||||
if (!feature.depends_on_code) return false;
|
||||
const parentFeature = features?.find(f => f.code === feature.depends_on_code);
|
||||
if (!parentFeature) return false;
|
||||
const parentValue = values[parentFeature.tenant_field_name];
|
||||
return !parentValue;
|
||||
};
|
||||
|
||||
// Handle value change
|
||||
const handleChange = (feature: BillingFeature, newValue: boolean | number | null) => {
|
||||
onChange(feature.tenant_field_name, newValue);
|
||||
|
||||
// If disabling a parent feature, also disable dependents
|
||||
if (feature.feature_type === 'boolean' && !newValue) {
|
||||
const dependents = features?.filter(f => f.depends_on_code === feature.code) ?? [];
|
||||
for (const dep of dependents) {
|
||||
if (values[dep.tenant_field_name]) {
|
||||
onChange(dep.tenant_field_name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showHeader && (
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
{headerTitle}
|
||||
</h3>
|
||||
)}
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[1, 2, 3, 4, 5, 6].map(i => (
|
||||
<div key={i} className="h-12 bg-gray-100 dark:bg-gray-800 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showHeader && (
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
{headerTitle}
|
||||
</h3>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle size={16} />
|
||||
<span className="text-sm">Failed to load features from billing system</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showHeader && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
{headerTitle}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available. Features are loaded from the billing system.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sortedCategories.map(category => {
|
||||
const categoryFeatures = groupedFeatures[category];
|
||||
if (!categoryFeatures || categoryFeatures.length === 0) return null;
|
||||
|
||||
const categoryMeta = FEATURE_CATEGORY_META[category];
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
{categoryMeta?.label || category}
|
||||
</h4>
|
||||
<div className={`grid ${gridCols[columns]} gap-3`}>
|
||||
{categoryFeatures.map(feature => {
|
||||
const isDisabled = isDependencyDisabled(feature);
|
||||
const currentValue = values[feature.tenant_field_name];
|
||||
|
||||
if (feature.feature_type === 'boolean') {
|
||||
const isChecked = currentValue === true;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={feature.code}
|
||||
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
|
||||
isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleChange(feature, e.target.checked)}
|
||||
disabled={isDisabled}
|
||||
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 block">
|
||||
{feature.name}
|
||||
</span>
|
||||
{showDescriptions && feature.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{feature.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Integer feature (limit)
|
||||
const intValue = typeof currentValue === 'number' ? currentValue : 0;
|
||||
const isUnlimited = currentValue === null || currentValue === -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.code}
|
||||
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300 block mb-1">
|
||||
{feature.name}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={isUnlimited ? -1 : intValue}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
handleChange(feature, val === -1 ? null : val);
|
||||
}}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{showDescriptions && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-1">
|
||||
{feature.description} (-1 = unlimited)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Show dependency hint for plugins category */}
|
||||
{category === 'plugins' && (
|
||||
(() => {
|
||||
const pluginsFeature = categoryFeatures.find(f => f.code === 'can_use_plugins');
|
||||
if (pluginsFeature && !values[pluginsFeature.tenant_field_name]) {
|
||||
return (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
Enable "Use Plugins" to allow dependent features
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicFeaturesEditor;
|
||||
531
frontend/src/components/platform/FeaturesPermissionsEditor.tsx
Normal file
531
frontend/src/components/platform/FeaturesPermissionsEditor.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* FeaturesPermissionsEditor
|
||||
*
|
||||
* A unified component for editing features and permissions.
|
||||
* Used by both subscription plan editing (PlatformSettings) and
|
||||
* individual business editing (BusinessEditModal).
|
||||
*
|
||||
* Supports two modes:
|
||||
* - 'plan': For editing subscription plan permissions (uses plan-style keys)
|
||||
* - 'business': For editing individual business permissions (uses tenant-style keys)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Key } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Permission definition with metadata
|
||||
*/
|
||||
interface PermissionDefinition {
|
||||
key: string;
|
||||
planKey?: string; // Key used in subscription plan permissions JSON
|
||||
businessKey?: string; // Key used in tenant/business model fields
|
||||
label: string;
|
||||
description?: string;
|
||||
category: PermissionCategory;
|
||||
dependsOn?: string; // Key of permission this depends on
|
||||
}
|
||||
|
||||
type PermissionCategory =
|
||||
| 'payments'
|
||||
| 'communication'
|
||||
| 'customization'
|
||||
| 'plugins'
|
||||
| 'advanced'
|
||||
| 'enterprise'
|
||||
| 'scheduling';
|
||||
|
||||
/**
|
||||
* All available permissions with their mappings
|
||||
*/
|
||||
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
// Payments & Revenue
|
||||
{
|
||||
key: 'can_accept_payments',
|
||||
planKey: 'can_accept_payments',
|
||||
businessKey: 'can_accept_payments',
|
||||
label: 'Online Payments',
|
||||
description: 'Accept payments via Stripe Connect',
|
||||
category: 'payments',
|
||||
},
|
||||
{
|
||||
key: 'can_process_refunds',
|
||||
planKey: 'can_process_refunds',
|
||||
businessKey: 'can_process_refunds',
|
||||
label: 'Process Refunds',
|
||||
description: 'Issue refunds for payments',
|
||||
category: 'payments',
|
||||
},
|
||||
{
|
||||
key: 'can_create_packages',
|
||||
planKey: 'can_create_packages',
|
||||
businessKey: 'can_create_packages',
|
||||
label: 'Service Packages',
|
||||
description: 'Create and sell service packages',
|
||||
category: 'payments',
|
||||
},
|
||||
{
|
||||
key: 'can_use_pos',
|
||||
planKey: 'can_use_pos',
|
||||
businessKey: 'can_use_pos',
|
||||
label: 'POS System',
|
||||
description: 'Point of sale for in-person payments',
|
||||
category: 'payments',
|
||||
},
|
||||
|
||||
// Communication
|
||||
{
|
||||
key: 'sms_reminders',
|
||||
planKey: 'sms_reminders',
|
||||
businessKey: 'can_use_sms_reminders',
|
||||
label: 'SMS Reminders',
|
||||
description: 'Send SMS appointment reminders',
|
||||
category: 'communication',
|
||||
},
|
||||
{
|
||||
key: 'masked_calling',
|
||||
planKey: 'can_use_masked_phone_numbers',
|
||||
businessKey: 'can_use_masked_phone_numbers',
|
||||
label: 'Masked Calling',
|
||||
description: 'Use masked phone numbers for privacy',
|
||||
category: 'communication',
|
||||
},
|
||||
{
|
||||
key: 'email_templates',
|
||||
planKey: 'can_use_email_templates',
|
||||
businessKey: 'can_use_email_templates',
|
||||
label: 'Email Templates',
|
||||
description: 'Custom email templates for communications',
|
||||
category: 'communication',
|
||||
},
|
||||
|
||||
// Customization
|
||||
{
|
||||
key: 'custom_booking_page',
|
||||
planKey: 'can_customize_booking_page',
|
||||
businessKey: 'can_customize_booking_page',
|
||||
label: 'Custom Booking Page',
|
||||
description: 'Customize the public booking page',
|
||||
category: 'customization',
|
||||
},
|
||||
{
|
||||
key: 'custom_domain',
|
||||
planKey: 'can_use_custom_domain',
|
||||
businessKey: 'can_use_custom_domain',
|
||||
label: 'Custom Domains',
|
||||
description: 'Use your own domain for booking',
|
||||
category: 'customization',
|
||||
},
|
||||
{
|
||||
key: 'white_label',
|
||||
planKey: 'can_white_label',
|
||||
businessKey: 'can_white_label',
|
||||
label: 'White Labelling',
|
||||
description: 'Remove SmoothSchedule branding',
|
||||
category: 'customization',
|
||||
},
|
||||
|
||||
// Plugins & Automation
|
||||
{
|
||||
key: 'plugins',
|
||||
planKey: 'can_use_plugins',
|
||||
businessKey: 'can_use_plugins',
|
||||
label: 'Use Plugins',
|
||||
description: 'Install and use marketplace plugins',
|
||||
category: 'plugins',
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
planKey: 'can_use_tasks',
|
||||
businessKey: 'can_use_tasks',
|
||||
label: 'Scheduled Tasks',
|
||||
description: 'Create automated scheduled tasks',
|
||||
category: 'plugins',
|
||||
dependsOn: 'plugins',
|
||||
},
|
||||
{
|
||||
key: 'create_plugins',
|
||||
planKey: 'can_create_plugins',
|
||||
businessKey: 'can_create_plugins',
|
||||
label: 'Create Plugins',
|
||||
description: 'Build custom plugins',
|
||||
category: 'plugins',
|
||||
dependsOn: 'plugins',
|
||||
},
|
||||
|
||||
// Advanced Features
|
||||
{
|
||||
key: 'api_access',
|
||||
planKey: 'can_api_access',
|
||||
businessKey: 'can_api_access',
|
||||
label: 'API Access',
|
||||
description: 'Access REST API for integrations',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'webhooks',
|
||||
planKey: 'can_use_webhooks',
|
||||
businessKey: 'can_use_webhooks',
|
||||
label: 'Webhooks',
|
||||
description: 'Receive webhook notifications',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'calendar_sync',
|
||||
planKey: 'calendar_sync',
|
||||
businessKey: 'can_use_calendar_sync',
|
||||
label: 'Calendar Sync',
|
||||
description: 'Sync with Google Calendar, etc.',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'export_data',
|
||||
planKey: 'can_export_data',
|
||||
businessKey: 'can_export_data',
|
||||
label: 'Data Export',
|
||||
description: 'Export data to CSV/Excel',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'video_conferencing',
|
||||
planKey: 'video_conferencing',
|
||||
businessKey: 'can_add_video_conferencing',
|
||||
label: 'Video Conferencing',
|
||||
description: 'Add video links to appointments',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'advanced_reporting',
|
||||
planKey: 'advanced_reporting',
|
||||
businessKey: 'advanced_reporting',
|
||||
label: 'Advanced Analytics',
|
||||
description: 'Detailed reporting and analytics',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'contracts',
|
||||
planKey: 'contracts_enabled',
|
||||
businessKey: 'can_use_contracts',
|
||||
label: 'Contracts',
|
||||
description: 'Create and manage e-signature contracts',
|
||||
category: 'advanced',
|
||||
},
|
||||
{
|
||||
key: 'mobile_app',
|
||||
planKey: 'can_use_mobile_app',
|
||||
businessKey: 'can_use_mobile_app',
|
||||
label: 'Mobile App',
|
||||
description: 'Access via mobile application',
|
||||
category: 'advanced',
|
||||
},
|
||||
|
||||
// Enterprise & Security
|
||||
{
|
||||
key: 'manage_oauth',
|
||||
planKey: 'can_manage_oauth_credentials',
|
||||
businessKey: 'can_manage_oauth_credentials',
|
||||
label: 'Manage OAuth',
|
||||
description: 'Configure custom OAuth credentials',
|
||||
category: 'enterprise',
|
||||
},
|
||||
{
|
||||
key: 'require_2fa',
|
||||
planKey: 'can_require_2fa',
|
||||
businessKey: 'can_require_2fa',
|
||||
label: 'Require 2FA',
|
||||
description: 'Enforce two-factor authentication',
|
||||
category: 'enterprise',
|
||||
},
|
||||
{
|
||||
key: 'sso_enabled',
|
||||
planKey: 'sso_enabled',
|
||||
businessKey: 'sso_enabled',
|
||||
label: 'SSO / SAML',
|
||||
description: 'Single sign-on integration',
|
||||
category: 'enterprise',
|
||||
},
|
||||
{
|
||||
key: 'priority_support',
|
||||
planKey: 'priority_support',
|
||||
businessKey: 'priority_support',
|
||||
label: 'Priority Support',
|
||||
description: 'Faster response times',
|
||||
category: 'enterprise',
|
||||
},
|
||||
{
|
||||
key: 'dedicated_support',
|
||||
planKey: 'dedicated_support',
|
||||
businessKey: 'dedicated_support',
|
||||
label: 'Dedicated Support',
|
||||
description: 'Dedicated account manager',
|
||||
category: 'enterprise',
|
||||
},
|
||||
|
||||
// Scheduling
|
||||
{
|
||||
key: 'repeated_events',
|
||||
planKey: 'can_book_repeated_events',
|
||||
businessKey: 'can_book_repeated_events',
|
||||
label: 'Recurring Events',
|
||||
description: 'Schedule recurring appointments',
|
||||
category: 'scheduling',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Category metadata for display
|
||||
*/
|
||||
const CATEGORY_META: Record<PermissionCategory, { label: string; order: number }> = {
|
||||
payments: { label: 'Payments & Revenue', order: 1 },
|
||||
communication: { label: 'Communication', order: 2 },
|
||||
customization: { label: 'Customization', order: 3 },
|
||||
plugins: { label: 'Plugins & Automation', order: 4 },
|
||||
advanced: { label: 'Advanced Features', order: 5 },
|
||||
scheduling: { label: 'Scheduling', order: 6 },
|
||||
enterprise: { label: 'Enterprise & Security', order: 7 },
|
||||
};
|
||||
|
||||
export type EditorMode = 'plan' | 'business';
|
||||
|
||||
export interface FeaturesPermissionsEditorProps {
|
||||
/**
|
||||
* Mode determines which keys are used and which permissions are shown
|
||||
*/
|
||||
mode: EditorMode;
|
||||
|
||||
/**
|
||||
* Current permission values
|
||||
* For 'plan' mode: the permissions object from subscription plan
|
||||
* For 'business' mode: flat object with tenant field names
|
||||
*/
|
||||
values: Record<string, boolean>;
|
||||
|
||||
/**
|
||||
* Callback when a permission changes
|
||||
*/
|
||||
onChange: (key: string, value: boolean) => void;
|
||||
|
||||
/**
|
||||
* Optional: Limit which categories to show
|
||||
*/
|
||||
categories?: PermissionCategory[];
|
||||
|
||||
/**
|
||||
* Optional: Limit which permissions to show by key
|
||||
*/
|
||||
includeOnly?: string[];
|
||||
|
||||
/**
|
||||
* Optional: Hide specific permissions
|
||||
*/
|
||||
exclude?: string[];
|
||||
|
||||
/**
|
||||
* Number of columns in the grid (default: 3)
|
||||
*/
|
||||
columns?: 2 | 3 | 4;
|
||||
|
||||
/**
|
||||
* Show section header
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Custom header title
|
||||
*/
|
||||
headerTitle?: string;
|
||||
|
||||
/**
|
||||
* Show descriptions under labels
|
||||
*/
|
||||
showDescriptions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate key for a permission based on mode
|
||||
*/
|
||||
export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string {
|
||||
if (mode === 'plan') {
|
||||
return def.planKey || def.key;
|
||||
}
|
||||
return def.businessKey || def.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert permissions from one mode to another
|
||||
*/
|
||||
export function convertPermissions(
|
||||
values: Record<string, boolean>,
|
||||
fromMode: EditorMode,
|
||||
toMode: EditorMode
|
||||
): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
|
||||
for (const def of PERMISSION_DEFINITIONS) {
|
||||
const fromKey = getPermissionKey(def, fromMode);
|
||||
const toKey = getPermissionKey(def, toMode);
|
||||
|
||||
if (fromKey in values) {
|
||||
result[toKey] = values[fromKey];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission value from values object
|
||||
*/
|
||||
function getPermissionValue(
|
||||
values: Record<string, boolean>,
|
||||
def: PermissionDefinition,
|
||||
mode: EditorMode
|
||||
): boolean {
|
||||
const key = getPermissionKey(def, mode);
|
||||
return values[key] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a dependent permission should be disabled
|
||||
*/
|
||||
function isDependencyDisabled(
|
||||
values: Record<string, boolean>,
|
||||
def: PermissionDefinition,
|
||||
mode: EditorMode
|
||||
): boolean {
|
||||
if (!def.dependsOn) return false;
|
||||
|
||||
const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn);
|
||||
if (!parentDef) return false;
|
||||
|
||||
return !getPermissionValue(values, parentDef, mode);
|
||||
}
|
||||
|
||||
const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
|
||||
mode,
|
||||
values,
|
||||
onChange,
|
||||
categories,
|
||||
includeOnly,
|
||||
exclude = [],
|
||||
columns = 3,
|
||||
showHeader = true,
|
||||
headerTitle = 'Features & Permissions',
|
||||
showDescriptions = false,
|
||||
}) => {
|
||||
// Filter permissions based on props
|
||||
const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => {
|
||||
if (exclude.includes(def.key)) return false;
|
||||
if (includeOnly && !includeOnly.includes(def.key)) return false;
|
||||
if (categories && !categories.includes(def.category)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Group by category
|
||||
const groupedPermissions = filteredPermissions.reduce((acc, def) => {
|
||||
if (!acc[def.category]) {
|
||||
acc[def.category] = [];
|
||||
}
|
||||
acc[def.category].push(def);
|
||||
return acc;
|
||||
}, {} as Record<PermissionCategory, PermissionDefinition[]>);
|
||||
|
||||
// Sort categories by order
|
||||
const sortedCategories = Object.keys(groupedPermissions).sort(
|
||||
(a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order
|
||||
) as PermissionCategory[];
|
||||
|
||||
const handleChange = (def: PermissionDefinition, checked: boolean) => {
|
||||
const key = getPermissionKey(def, mode);
|
||||
onChange(key, checked);
|
||||
|
||||
// If disabling a parent permission, also disable dependents
|
||||
if (!checked) {
|
||||
const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key);
|
||||
for (const dep of dependents) {
|
||||
const depKey = getPermissionKey(dep, mode);
|
||||
if (values[depKey]) {
|
||||
onChange(depKey, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showHeader && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
{headerTitle}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sortedCategories.map(category => (
|
||||
<div key={category}>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
{CATEGORY_META[category].label}
|
||||
</h4>
|
||||
<div className={`grid ${gridCols[columns]} gap-3`}>
|
||||
{groupedPermissions[category].map(def => {
|
||||
const isChecked = getPermissionValue(values, def, mode);
|
||||
const isDisabled = isDependencyDisabled(values, def, mode);
|
||||
const key = getPermissionKey(def, mode);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={def.key}
|
||||
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
|
||||
isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleChange(def, e.target.checked)}
|
||||
disabled={isDisabled}
|
||||
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 block">
|
||||
{def.label}
|
||||
</span>
|
||||
{showDescriptions && def.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{def.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Show dependency hint for plugins category */}
|
||||
{category === 'plugins' && !getPermissionValue(
|
||||
values,
|
||||
PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!,
|
||||
mode
|
||||
) && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturesPermissionsEditor;
|
||||
149
frontend/src/components/services/CustomerPreview.tsx
Normal file
149
frontend/src/components/services/CustomerPreview.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Clock,
|
||||
DollarSign,
|
||||
Image as ImageIcon,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Service, Business } from '../../types';
|
||||
import Badge from '../ui/Badge';
|
||||
|
||||
interface CustomerPreviewProps {
|
||||
service: Service | null; // Null when creating new
|
||||
business: Business;
|
||||
previewData?: Partial<Service>; // Live form data
|
||||
}
|
||||
|
||||
export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
|
||||
service,
|
||||
business,
|
||||
previewData
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Merge existing service data with live form preview
|
||||
const data = {
|
||||
...service,
|
||||
...previewData,
|
||||
price: previewData?.price ?? service?.price ?? 0,
|
||||
name: previewData?.name || service?.name || 'New Service',
|
||||
description: previewData?.description || service?.description || 'Service description will appear here...',
|
||||
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
|
||||
photos: previewData?.photos ?? service?.photos ?? [],
|
||||
};
|
||||
|
||||
// Get the first photo for the cover image
|
||||
const coverPhoto = data.photos && data.photos.length > 0 ? data.photos[0] : null;
|
||||
|
||||
const formatPrice = (price: number | string) => {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(numPrice);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Customer Preview
|
||||
</h3>
|
||||
<Badge variant="info" size="sm">Live Preview</Badge>
|
||||
</div>
|
||||
|
||||
{/* Lumina-style Horizontal Card */}
|
||||
<div className="relative overflow-hidden rounded-xl border-2 border-brand-600 bg-brand-50/50 dark:bg-brand-900/20 ring-2 ring-brand-600 ring-offset-2 dark:ring-offset-gray-900 transition-all duration-200">
|
||||
<div className="flex h-full min-h-[180px]">
|
||||
{/* Image Section - 1/3 width */}
|
||||
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
|
||||
{coverPhoto ? (
|
||||
<img
|
||||
src={coverPhoto}
|
||||
alt={data.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="w-12 h-12 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section - 2/3 width */}
|
||||
<div className="w-2/3 p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
{/* Category Badge */}
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/50 px-2.5 py-0.5 text-xs font-medium text-brand-800 dark:text-brand-300">
|
||||
{data.category?.name || 'General'}
|
||||
</span>
|
||||
{data.variable_pricing && (
|
||||
<span className="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:text-purple-300">
|
||||
Variable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{data.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottom Info */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center text-gray-600 dark:text-gray-300">
|
||||
<Clock className="w-4 h-4 mr-1.5" />
|
||||
{data.durationMinutes} mins
|
||||
</div>
|
||||
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
|
||||
{data.variable_pricing ? (
|
||||
<span className="text-purple-600 dark:text-purple-400">Price varies</span>
|
||||
) : (
|
||||
<>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
{data.price}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deposit Info */}
|
||||
{((data.deposit_amount && data.deposit_amount > 0) || (data.variable_pricing && data.deposit_amount)) && (
|
||||
<div className="mt-2 text-xs text-brand-600 dark:text-brand-400 font-medium">
|
||||
Deposit required: {formatPrice(data.deposit_amount || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Note */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
|
||||
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
This is how your service will appear to customers on your booking page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerPreview;
|
||||
153
frontend/src/components/services/ResourceSelector.tsx
Normal file
153
frontend/src/components/services/ResourceSelector.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Resource } from '../../types';
|
||||
import FormInput from '../ui/FormInput';
|
||||
import Badge from '../ui/Badge';
|
||||
|
||||
interface ResourceSelectorProps {
|
||||
resources: Resource[];
|
||||
selectedIds: string[];
|
||||
allSelected: boolean;
|
||||
onChange: (ids: string[], all: boolean) => void;
|
||||
}
|
||||
|
||||
export const ResourceSelector: React.FC<ResourceSelectorProps> = ({
|
||||
resources,
|
||||
selectedIds,
|
||||
allSelected,
|
||||
onChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const filteredResources = resources.filter(r =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
if (allSelected) {
|
||||
// If switching from All to Specific, start with just this one selected?
|
||||
// Or keep all others selected?
|
||||
// Better UX: "All" is a special mode. If you uncheck one, you enter "Specific" mode with all-minus-one selected.
|
||||
// But we don't have all IDs readily available without mapping.
|
||||
// Let's assume typical toggle logic.
|
||||
|
||||
// Actually, if "All" is true, we should probably toggle it OFF and select just this ID?
|
||||
// Or select all EXCEPT this ID?
|
||||
// Let's simplify: Toggle "All Staff" switch separately.
|
||||
return;
|
||||
}
|
||||
|
||||
const newIds = selectedIds.includes(id)
|
||||
? selectedIds.filter(i => i !== id)
|
||||
: [...selectedIds, id];
|
||||
|
||||
onChange(newIds, false);
|
||||
};
|
||||
|
||||
const handleAllToggle = () => {
|
||||
if (!allSelected) {
|
||||
onChange([], true);
|
||||
} else {
|
||||
onChange([], false); // Clear selection or keep? Let's clear for now.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header / All Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border 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 text-brand-600 dark:text-brand-400">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">All Staff Available</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically include current and future staff
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={allSelected}
|
||||
onChange={handleAllToggle}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!allSelected && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search staff..."
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-brand-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-2 bg-white dark:bg-gray-800 space-y-1">
|
||||
{filteredResources.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
No staff found matching "{search}"
|
||||
</div>
|
||||
) : (
|
||||
filteredResources.map(resource => (
|
||||
<button
|
||||
key={resource.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(resource.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
|
||||
selectedIds.includes(resource.id)
|
||||
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
selectedIds.includes(resource.id)
|
||||
? 'bg-brand-200 dark:bg-brand-800 text-brand-700 dark:text-brand-300'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{resource.name.charAt(0)}
|
||||
</div>
|
||||
<span>{resource.name}</span>
|
||||
</div>
|
||||
{selectedIds.includes(resource.id) && (
|
||||
<Check size={18} className="text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 flex justify-between">
|
||||
<span>{selectedIds.length} staff selected</span>
|
||||
{selectedIds.length === 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<AlertCircle size={12} /> At least one required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceSelector;
|
||||
131
frontend/src/components/services/ServiceListItem.tsx
Normal file
131
frontend/src/components/services/ServiceListItem.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
DollarSign,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Service } from '../../types';
|
||||
import Badge from '../ui/Badge';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface ServiceListItemProps {
|
||||
service: Service;
|
||||
onEdit: (service: Service) => void;
|
||||
onDelete: (service: Service) => void;
|
||||
dragHandleProps?: any;
|
||||
}
|
||||
|
||||
export const ServiceListItem: React.FC<ServiceListItemProps> = ({
|
||||
service,
|
||||
onEdit,
|
||||
onDelete,
|
||||
dragHandleProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formatPrice = (price: number | string) => {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(numPrice);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all duration-200">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing p-1"
|
||||
>
|
||||
<GripVertical size={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-4 gap-4 items-center">
|
||||
{/* Name & Description */}
|
||||
<div className="sm:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{service.name}
|
||||
</h3>
|
||||
{service.category && (
|
||||
<Badge variant="default" size="sm" className="hidden sm:inline-flex">
|
||||
{service.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{service.description || 'No description provided'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
|
||||
<Clock size={16} className="text-brand-500" />
|
||||
<span className="font-medium">{formatDuration(service.durationMinutes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
|
||||
<DollarSign size={16} className="text-green-500" />
|
||||
<span className="font-medium">
|
||||
{service.variable_pricing ? (
|
||||
<span className="italic">Variable</span>
|
||||
) : (
|
||||
formatPrice(service.price)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="hidden sm:flex items-center justify-end gap-3 text-xs text-gray-500">
|
||||
{service.all_resources ? (
|
||||
<span className="flex items-center gap-1" title="Available to all staff">
|
||||
<Users size={14} /> All Staff
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1" title="Restricted to specific staff">
|
||||
<Users size={14} /> {service.resource_ids?.length || 0} Staff
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pl-4 border-l border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onEdit(service)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded-lg transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(service)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceListItem;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ServiceListItem from '../ServiceListItem';
|
||||
import { Service } from '../../../types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string, val: string) => val || key }),
|
||||
}));
|
||||
|
||||
// Mock Lucide icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Clock: () => <span data-testid="icon-clock" />,
|
||||
Users: () => <span data-testid="icon-users" />,
|
||||
MoreVertical: () => <span data-testid="icon-more" />,
|
||||
Pencil: () => <span data-testid="icon-pencil" />,
|
||||
Trash2: () => <span data-testid="icon-trash" />,
|
||||
GripVertical: () => <span data-testid="icon-grip" />,
|
||||
DollarSign: () => <span data-testid="icon-dollar" />,
|
||||
AlertCircle: () => <span data-testid="icon-alert" />,
|
||||
}));
|
||||
|
||||
const mockService: Service = {
|
||||
id: '1',
|
||||
name: 'Test Service',
|
||||
description: 'Test Description',
|
||||
durationMinutes: 60,
|
||||
price: 50,
|
||||
variable_pricing: false,
|
||||
all_resources: true,
|
||||
resource_ids: [],
|
||||
category: { id: 'cat1', name: 'Category 1' }
|
||||
} as any; // Cast to avoid strict type checks on missing optional fields
|
||||
|
||||
describe('ServiceListItem', () => {
|
||||
it('renders service details correctly', () => {
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={mockService}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Category 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('1h')).toBeInTheDocument(); // 60 mins
|
||||
expect(screen.getByText('$50.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders variable pricing correctly', () => {
|
||||
const variableService = { ...mockService, variable_pricing: true };
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={variableService}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Variable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers action callbacks', () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<ServiceListItem
|
||||
service={mockService}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Edit'));
|
||||
expect(onEdit).toHaveBeenCalledWith(mockService);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete'));
|
||||
expect(onDelete).toHaveBeenCalledWith(mockService);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { BlockedDate, BlockType } from '../../types';
|
||||
import { BlockedDate, BlockType, BlockPurpose } from '../../types';
|
||||
|
||||
interface TimeBlockCalendarOverlayProps {
|
||||
blockedDates: BlockedDate[];
|
||||
@@ -126,61 +126,46 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
return overlays;
|
||||
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
|
||||
|
||||
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const getBlockStyle = (blockType: BlockType, purpose: BlockPurpose, isBusinessLevel: boolean): React.CSSProperties => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'default',
|
||||
zIndex: 5, // Ensure overlays are visible above grid lines
|
||||
};
|
||||
|
||||
// Business-level blocks (including business hours): Simple gray background
|
||||
// No fancy styling - just indicates "not available for booking"
|
||||
if (isBusinessLevel) {
|
||||
// Business blocks: Red (hard) / Amber (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(239, 68, 68, 0.3) 5px,
|
||||
rgba(239, 68, 68, 0.5) 5px,
|
||||
rgba(239, 68, 68, 0.5) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(251, 191, 36, 0.2)',
|
||||
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
|
||||
};
|
||||
}
|
||||
|
||||
// Resource-level blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
// Resource blocks: Purple (hard) / Cyan (soft)
|
||||
if (blockType === 'HARD') {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: `repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgba(147, 51, 234, 0.25),
|
||||
rgba(147, 51, 234, 0.25) 5px,
|
||||
rgba(147, 51, 234, 0.4) 5px,
|
||||
rgba(147, 51, 234, 0.4) 10px
|
||||
)`,
|
||||
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...baseStyle,
|
||||
background: 'rgba(6, 182, 212, 0.15)',
|
||||
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,7 +193,7 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
<>
|
||||
{blockOverlays.map((overlay, index) => {
|
||||
const isBusinessLevel = overlay.block.resource_id === null;
|
||||
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
|
||||
const style = getBlockStyle(overlay.block.block_type, overlay.block.purpose, isBusinessLevel);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -224,14 +209,12 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => onDayClick?.(days[overlay.dayIndex])}
|
||||
>
|
||||
{/* Block level indicator */}
|
||||
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
|
||||
isBusinessLevel
|
||||
? 'bg-red-600'
|
||||
: 'bg-purple-600'
|
||||
}`}>
|
||||
{isBusinessLevel ? 'B' : 'R'}
|
||||
</div>
|
||||
{/* Only show badge for resource-level blocks */}
|
||||
{!isBusinessLevel && (
|
||||
<div className="absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide bg-purple-600">
|
||||
R
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
Loader2,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../Portal';
|
||||
import {
|
||||
@@ -40,8 +41,11 @@ import {
|
||||
Holiday,
|
||||
Resource,
|
||||
TimeBlockListItem,
|
||||
Location,
|
||||
} from '../../types';
|
||||
import { formatLocalDate } from '../../utils/dateUtils';
|
||||
import { LocationSelector, useShouldShowLocationSelector, useAutoSelectLocation } from '../LocationSelector';
|
||||
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
|
||||
|
||||
// Preset block types
|
||||
const PRESETS = [
|
||||
@@ -155,6 +159,7 @@ interface TimeBlockCreatorModalProps {
|
||||
editingBlock?: TimeBlockListItem | null;
|
||||
holidays: Holiday[];
|
||||
resources: Resource[];
|
||||
locations?: Location[];
|
||||
isResourceLevel?: boolean;
|
||||
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
|
||||
staffMode?: boolean;
|
||||
@@ -162,6 +167,9 @@ interface TimeBlockCreatorModalProps {
|
||||
staffResourceId?: string | number | null;
|
||||
}
|
||||
|
||||
// Block level types for the three-tier system
|
||||
type BlockLevel = 'business' | 'location' | 'resource';
|
||||
|
||||
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
||||
|
||||
const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
@@ -172,6 +180,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
editingBlock,
|
||||
holidays,
|
||||
resources,
|
||||
locations = [],
|
||||
isResourceLevel: initialIsResourceLevel = false,
|
||||
staffMode = false,
|
||||
staffResourceId = null,
|
||||
@@ -181,6 +190,18 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [isResourceLevel, setIsResourceLevel] = useState(initialIsResourceLevel);
|
||||
|
||||
// Multi-location support
|
||||
const { canUse } = usePlanFeatures();
|
||||
const hasMultiLocation = canUse('multi_location');
|
||||
const showLocationSelector = useShouldShowLocationSelector();
|
||||
const [blockLevel, setBlockLevel] = useState<BlockLevel>(
|
||||
initialIsResourceLevel ? 'resource' : 'business'
|
||||
);
|
||||
const [locationId, setLocationId] = useState<number | null>(null);
|
||||
|
||||
// Auto-select location when only one exists
|
||||
useAutoSelectLocation(locationId, setLocationId);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||
const [description, setDescription] = useState(editingBlock?.description || '');
|
||||
@@ -233,7 +254,21 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setStartTime(editingBlock.start_time || '09:00');
|
||||
setEndTime(editingBlock.end_time || '17:00');
|
||||
setResourceId(editingBlock.resource || null);
|
||||
setIsResourceLevel(!!editingBlock.resource); // Set level based on whether block has a resource
|
||||
setLocationId(editingBlock.location ?? null);
|
||||
// Determine block level based on existing data
|
||||
if (editingBlock.is_business_wide) {
|
||||
setBlockLevel('business');
|
||||
setIsResourceLevel(false);
|
||||
} else if (editingBlock.location && !editingBlock.resource) {
|
||||
setBlockLevel('location');
|
||||
setIsResourceLevel(false);
|
||||
} else if (editingBlock.resource) {
|
||||
setBlockLevel('resource');
|
||||
setIsResourceLevel(true);
|
||||
} else {
|
||||
setBlockLevel('business');
|
||||
setIsResourceLevel(false);
|
||||
}
|
||||
// Parse dates if available
|
||||
if (editingBlock.start_date) {
|
||||
const startDate = new Date(editingBlock.start_date);
|
||||
@@ -288,8 +323,10 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setHolidayCodes([]);
|
||||
setRecurrenceStart('');
|
||||
setRecurrenceEnd('');
|
||||
setLocationId(null);
|
||||
// In staff mode, always resource-level
|
||||
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||
setBlockLevel(staffMode ? 'resource' : (initialIsResourceLevel ? 'resource' : 'business'));
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||
@@ -381,12 +418,37 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
// In staff mode, always use the staff's resource ID
|
||||
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||
|
||||
// Determine location and resource based on block level
|
||||
let effectiveLocation: number | null = null;
|
||||
let effectiveResource: string | number | null = null;
|
||||
let isBusinessWide = false;
|
||||
|
||||
switch (blockLevel) {
|
||||
case 'business':
|
||||
isBusinessWide = true;
|
||||
effectiveLocation = null;
|
||||
effectiveResource = null;
|
||||
break;
|
||||
case 'location':
|
||||
isBusinessWide = false;
|
||||
effectiveLocation = locationId;
|
||||
effectiveResource = null;
|
||||
break;
|
||||
case 'resource':
|
||||
isBusinessWide = false;
|
||||
effectiveLocation = locationId; // Resource blocks can optionally have a location
|
||||
effectiveResource = effectiveResourceId;
|
||||
break;
|
||||
}
|
||||
|
||||
const baseData: any = {
|
||||
description: description || undefined,
|
||||
block_type: blockType,
|
||||
recurrence_type: recurrenceType,
|
||||
all_day: allDay,
|
||||
resource: isResourceLevel ? effectiveResourceId : null,
|
||||
resource: effectiveResource,
|
||||
location: effectiveLocation,
|
||||
is_business_wide: isBusinessWide,
|
||||
};
|
||||
|
||||
if (!allDay) {
|
||||
@@ -441,6 +503,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
if (!title.trim()) return false;
|
||||
// In staff mode, resource is auto-selected; otherwise check if selected
|
||||
if (isResourceLevel && !staffMode && !resourceId) return false;
|
||||
// Location is required when blockLevel is 'location'
|
||||
if (blockLevel === 'location' && !locationId) return false;
|
||||
return true;
|
||||
case 'schedule':
|
||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||
@@ -577,48 +641,87 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className={`grid gap-4 ${showLocationSelector ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
{/* Business-wide option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBlockLevel('business');
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
setLocationId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
!isResourceLevel
|
||||
blockLevel === 'business'
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
<div className={`p-2 rounded-lg ${blockLevel === 'business' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<Building2 size={20} className={blockLevel === 'business' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
<p className={`font-semibold ${blockLevel === 'business' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Business-wide
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Affects all resources
|
||||
{showLocationSelector ? 'All locations & resources' : 'Affects all resources'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Location-wide option - only show when multi-location is enabled */}
|
||||
{showLocationSelector && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBlockLevel('location');
|
||||
setIsResourceLevel(false);
|
||||
setResourceId(null);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
blockLevel === 'location'
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${blockLevel === 'location' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<MapPin size={20} className={blockLevel === 'location' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${blockLevel === 'location' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Location
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
All resources at one location
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Resource-specific option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsResourceLevel(true)}
|
||||
onClick={() => {
|
||||
setBlockLevel('resource');
|
||||
setIsResourceLevel(true);
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||
isResourceLevel
|
||||
blockLevel === 'resource'
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
<div className={`p-2 rounded-lg ${blockLevel === 'resource' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||
<User size={20} className={blockLevel === 'resource' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
<p className={`font-semibold ${blockLevel === 'resource' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
Specific Resource
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -628,6 +731,18 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Location Selector - show when location-level is selected */}
|
||||
{blockLevel === 'location' && showLocationSelector && (
|
||||
<div className="mt-4">
|
||||
<LocationSelector
|
||||
value={locationId}
|
||||
onChange={setLocationId}
|
||||
label="Location"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -661,20 +776,32 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
|
||||
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||
{isResourceLevel && !staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
</label>
|
||||
<select
|
||||
value={resourceId || ''}
|
||||
onChange={(e) => setResourceId(e.target.value || null)}
|
||||
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
|
||||
>
|
||||
<option value="">Select a resource...</option>
|
||||
{resources.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
</label>
|
||||
<select
|
||||
value={resourceId || ''}
|
||||
onChange={(e) => setResourceId(e.target.value || null)}
|
||||
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
|
||||
>
|
||||
<option value="">Select a resource...</option>
|
||||
{resources.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Optional location for resource-level blocks when multi-location is enabled */}
|
||||
{showLocationSelector && (
|
||||
<LocationSelector
|
||||
value={locationId}
|
||||
onChange={setLocationId}
|
||||
label="Location (optional)"
|
||||
hint="Optionally limit this block to a specific location"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1207,6 +1334,40 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{/* Block Level - show when multi-location is enabled or not in staff mode */}
|
||||
{!staffMode && (
|
||||
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Applies To</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm ${
|
||||
blockLevel === 'business'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: blockLevel === 'location'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}>
|
||||
{blockLevel === 'business' && <Building2 size={14} />}
|
||||
{blockLevel === 'location' && <MapPin size={14} />}
|
||||
{blockLevel === 'resource' && <User size={14} />}
|
||||
{blockLevel === 'business' && 'Business-wide'}
|
||||
{blockLevel === 'location' && 'Specific Location'}
|
||||
{blockLevel === 'resource' && 'Specific Resource'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location - show when location is selected */}
|
||||
{(blockLevel === 'location' || (blockLevel === 'resource' && locationId)) && locationId && (
|
||||
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Location</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
{locations.find(l => l.id === locationId)?.name || `Location ${locationId}`}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource - show for resource-level blocks */}
|
||||
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||
<div className="flex justify-between py-2">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||
|
||||
106
frontend/src/components/ui/Alert.tsx
Normal file
106
frontend/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
type AlertVariant = 'error' | 'success' | 'warning' | 'info';
|
||||
|
||||
interface AlertProps {
|
||||
variant: AlertVariant;
|
||||
message: string | React.ReactNode;
|
||||
title?: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
/** Compact mode for inline alerts */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const variantConfig: Record<AlertVariant, {
|
||||
icon: React.ReactNode;
|
||||
containerClass: string;
|
||||
textClass: string;
|
||||
titleClass: string;
|
||||
}> = {
|
||||
error: {
|
||||
icon: <AlertCircle size={20} />,
|
||||
containerClass: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
textClass: 'text-red-800 dark:text-red-200',
|
||||
titleClass: 'text-red-900 dark:text-red-100',
|
||||
},
|
||||
success: {
|
||||
icon: <CheckCircle size={20} />,
|
||||
containerClass: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
textClass: 'text-green-800 dark:text-green-200',
|
||||
titleClass: 'text-green-900 dark:text-green-100',
|
||||
},
|
||||
warning: {
|
||||
icon: <AlertTriangle size={20} />,
|
||||
containerClass: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800',
|
||||
textClass: 'text-amber-800 dark:text-amber-200',
|
||||
titleClass: 'text-amber-900 dark:text-amber-100',
|
||||
},
|
||||
info: {
|
||||
icon: <Info size={20} />,
|
||||
containerClass: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
textClass: 'text-blue-800 dark:text-blue-200',
|
||||
titleClass: 'text-blue-900 dark:text-blue-100',
|
||||
},
|
||||
};
|
||||
|
||||
export const Alert: React.FC<AlertProps> = ({
|
||||
variant,
|
||||
message,
|
||||
title,
|
||||
onDismiss,
|
||||
className = '',
|
||||
compact = false,
|
||||
}) => {
|
||||
const config = variantConfig[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${compact ? 'p-2' : 'p-3'} border rounded-lg ${config.containerClass} ${className}`}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`flex-shrink-0 ${config.textClass}`}>{config.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<p className={`font-medium ${config.titleClass} ${compact ? 'text-sm' : ''}`}>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<div className={`${compact ? 'text-xs' : 'text-sm'} ${config.textClass} ${title ? 'mt-1' : ''}`}>
|
||||
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className={`flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${config.textClass}`}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Convenience components */
|
||||
export const ErrorMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
|
||||
<Alert variant="error" {...props} />
|
||||
);
|
||||
|
||||
export const SuccessMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
|
||||
<Alert variant="success" {...props} />
|
||||
);
|
||||
|
||||
export const WarningMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
|
||||
<Alert variant="warning" {...props} />
|
||||
);
|
||||
|
||||
export const InfoMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
|
||||
<Alert variant="info" {...props} />
|
||||
);
|
||||
|
||||
export default Alert;
|
||||
61
frontend/src/components/ui/Badge.tsx
Normal file
61
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
type BadgeSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
/** Rounded pill style */
|
||||
pill?: boolean;
|
||||
/** Dot indicator before text */
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
|
||||
primary: 'bg-brand-100 dark:bg-brand-900/30 text-brand-800 dark:text-brand-200',
|
||||
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
|
||||
warning: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200',
|
||||
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
|
||||
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
|
||||
};
|
||||
|
||||
const dotColors: Record<BadgeVariant, string> = {
|
||||
default: 'bg-gray-400',
|
||||
primary: 'bg-brand-500',
|
||||
success: 'bg-green-500',
|
||||
warning: 'bg-amber-500',
|
||||
danger: 'bg-red-500',
|
||||
info: 'bg-blue-500',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<BadgeSize, string> = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-2.5 py-1.5 text-sm',
|
||||
};
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
pill = false,
|
||||
dot = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const roundedClass = pill ? 'rounded-full' : 'rounded';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 font-medium ${roundedClass} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
108
frontend/src/components/ui/Button.tsx
Normal file
108
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
|
||||
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
|
||||
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
|
||||
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
|
||||
success: 'bg-green-600 hover:bg-green-700 text-white border-transparent',
|
||||
warning: 'bg-amber-600 hover:bg-amber-700 text-white border-transparent',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
xs: 'px-2 py-1 text-xs',
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
};
|
||||
|
||||
const iconSizes: Record<ButtonSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2
|
||||
font-medium rounded-lg border
|
||||
transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className={`animate-spin ${iconSizes[size]}`} />
|
||||
{loadingText || children}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className={iconSizes[size]}>{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className={iconSizes[size]}>{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
/** Convenience component for submit buttons */
|
||||
export const SubmitButton: React.FC<Omit<ButtonProps, 'type'> & { submitText?: string }> = ({
|
||||
isLoading,
|
||||
submitText = 'Save',
|
||||
loadingText = 'Saving...',
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<Button type="submit" isLoading={isLoading} loadingText={loadingText} {...props}>
|
||||
{children || submitText}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default Button;
|
||||
88
frontend/src/components/ui/Card.tsx
Normal file
88
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Card padding */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
/** Show border */
|
||||
bordered?: boolean;
|
||||
/** Hover effect */
|
||||
hoverable?: boolean;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Action buttons for the header */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface CardBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
};
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
bordered = true,
|
||||
hoverable = false,
|
||||
onClick,
|
||||
}) => {
|
||||
const baseClasses = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm';
|
||||
const borderClass = bordered ? 'border border-gray-200 dark:border-gray-700' : '';
|
||||
const hoverClass = hoverable
|
||||
? 'hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition-all cursor-pointer'
|
||||
: '';
|
||||
const paddingClass = paddingClasses[padding];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${borderClass} ${hoverClass} ${paddingClass} ${className}`}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
actions,
|
||||
}) => (
|
||||
<div className={`flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{children}</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CardBody: React.FC<CardBodyProps> = ({ children, className = '' }) => (
|
||||
<div className={`py-4 ${className}`}>{children}</div>
|
||||
);
|
||||
|
||||
export const CardFooter: React.FC<CardFooterProps> = ({ children, className = '' }) => (
|
||||
<div className={`pt-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
166
frontend/src/components/ui/CurrencyInput.tsx
Normal file
166
frontend/src/components/ui/CurrencyInput.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface CurrencyInputProps {
|
||||
value: number; // Value in cents (integer)
|
||||
onChange: (cents: number) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency input where digits represent cents.
|
||||
* Only accepts integer input (0-9), no decimal points.
|
||||
* Allows normal text selection and editing.
|
||||
*
|
||||
* Examples:
|
||||
* - Type "5" → $0.05
|
||||
* - Type "50" → $0.50
|
||||
* - Type "500" → $5.00
|
||||
* - Type "1234" → $12.34
|
||||
*/
|
||||
const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
required = false,
|
||||
placeholder = '$0.00',
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [displayValue, setDisplayValue] = useState('');
|
||||
|
||||
// Format cents as dollars string (e.g., 1234 → "$12.34")
|
||||
const formatCentsAsDollars = (cents: number): string => {
|
||||
if (cents === 0) return '';
|
||||
const dollars = cents / 100;
|
||||
return `$${dollars.toFixed(2)}`;
|
||||
};
|
||||
|
||||
// Extract just the digits from a string
|
||||
const extractDigits = (str: string): string => {
|
||||
return str.replace(/\D/g, '');
|
||||
};
|
||||
|
||||
// Sync display value when external value changes
|
||||
useEffect(() => {
|
||||
setDisplayValue(formatCentsAsDollars(value));
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target.value;
|
||||
|
||||
// Extract only digits
|
||||
const digits = extractDigits(input);
|
||||
|
||||
// Convert to cents (the digits ARE the cents value)
|
||||
let cents = digits ? parseInt(digits, 10) : 0;
|
||||
|
||||
// Enforce max if specified
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
}
|
||||
|
||||
onChange(cents);
|
||||
|
||||
// Update display immediately with formatted value
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Allow: navigation, selection, delete, backspace, tab, escape, enter
|
||||
const allowedKeys = [
|
||||
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
|
||||
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
|
||||
'Home', 'End'
|
||||
];
|
||||
|
||||
if (allowedKeys.includes(e.key)) {
|
||||
return; // Let these through
|
||||
}
|
||||
|
||||
// Allow Ctrl/Cmd + A, C, V, X (select all, copy, paste, cut)
|
||||
if ((e.ctrlKey || e.metaKey) && ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow digits 0-9
|
||||
if (!/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
// Select all text for easy replacement
|
||||
setTimeout(() => {
|
||||
e.target.select();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Extract digits and reparse to enforce constraints
|
||||
const digits = extractDigits(displayValue);
|
||||
let cents = digits ? parseInt(digits, 10) : 0;
|
||||
|
||||
// Enforce min on blur if specified
|
||||
if (min !== undefined && cents < min && cents > 0) {
|
||||
cents = min;
|
||||
onChange(cents);
|
||||
}
|
||||
|
||||
// Enforce max on blur if specified
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
onChange(cents);
|
||||
}
|
||||
|
||||
// Reformat display
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const digits = extractDigits(pastedText);
|
||||
|
||||
if (digits) {
|
||||
let cents = parseInt(digits, 10);
|
||||
|
||||
if (max !== undefined && cents > max) {
|
||||
cents = max;
|
||||
}
|
||||
|
||||
onChange(cents);
|
||||
setDisplayValue(formatCentsAsDollars(cents));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencyInput;
|
||||
37
frontend/src/components/ui/EmptyState.tsx
Normal file
37
frontend/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Inbox } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-center py-12 px-4 ${className}`}>
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon || <Inbox className="h-12 w-12 text-gray-400" />}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mb-4">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
81
frontend/src/components/ui/FormCurrencyInput.tsx
Normal file
81
frontend/src/components/ui/FormCurrencyInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import CurrencyInput from './CurrencyInput';
|
||||
|
||||
interface FormCurrencyInputProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
value: number;
|
||||
onChange: (cents: number) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** Container class name */
|
||||
containerClassName?: string;
|
||||
/** Input class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form wrapper for CurrencyInput that adds label, error, and hint support.
|
||||
* Uses the ATM-style currency input where digits are entered as cents.
|
||||
*/
|
||||
export const FormCurrencyInput: React.FC<FormCurrencyInputProps> = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
required = false,
|
||||
placeholder = '$0.00',
|
||||
min,
|
||||
max,
|
||||
containerClassName = '',
|
||||
className = '',
|
||||
}) => {
|
||||
const baseInputClasses =
|
||||
'w-full px-3 py-2 border 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 transition-colors';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
const disabledClasses = disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<CurrencyInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
max={max}
|
||||
className={`${baseInputClasses} ${stateClasses} ${disabledClasses} ${className}`}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormCurrencyInput;
|
||||
104
frontend/src/components/ui/FormInput.tsx
Normal file
104
frontend/src/components/ui/FormInput.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
/** Size variant */
|
||||
inputSize?: 'sm' | 'md' | 'lg';
|
||||
/** Full width */
|
||||
fullWidth?: boolean;
|
||||
/** Icon to display on the left */
|
||||
leftIcon?: React.ReactNode;
|
||||
/** Icon to display on the right */
|
||||
rightIcon?: React.ReactNode;
|
||||
/** Container class name */
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-3 py-2',
|
||||
lg: 'px-4 py-3 text-lg',
|
||||
};
|
||||
|
||||
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
inputSize = 'md',
|
||||
fullWidth = true,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
containerClassName = '',
|
||||
className = '',
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputId = id || props.name || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseClasses =
|
||||
'border 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 transition-colors';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
const disabledClasses = props.disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
|
||||
: '';
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<div className={`${containerClassName}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={`relative ${widthClass}`}>
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[inputSize]} ${widthClass} ${leftIcon ? 'pl-10' : ''} ${rightIcon ? 'pr-10' : ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormInput.displayName = 'FormInput';
|
||||
|
||||
export default FormInput;
|
||||
115
frontend/src/components/ui/FormSelect.tsx
Normal file
115
frontend/src/components/ui/FormSelect.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
export interface SelectOption<T = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
options: SelectOption[];
|
||||
/** Size variant */
|
||||
selectSize?: 'sm' | 'md' | 'lg';
|
||||
/** Full width */
|
||||
fullWidth?: boolean;
|
||||
/** Placeholder option */
|
||||
placeholder?: string;
|
||||
/** Container class name */
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-sm',
|
||||
md: 'px-3 py-2',
|
||||
lg: 'px-4 py-3 text-lg',
|
||||
};
|
||||
|
||||
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
options,
|
||||
selectSize = 'md',
|
||||
fullWidth = true,
|
||||
placeholder,
|
||||
containerClassName = '',
|
||||
className = '',
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const selectId = id || props.name || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const baseClasses =
|
||||
'border 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 transition-colors appearance-none cursor-pointer';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
const disabledClasses = props.disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
|
||||
: '';
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<div className={`${containerClassName}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={`relative ${widthClass}`}>
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[selectSize]} ${widthClass} pr-10 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Custom dropdown arrow */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormSelect.displayName = 'FormSelect';
|
||||
|
||||
export default FormSelect;
|
||||
94
frontend/src/components/ui/FormTextarea.tsx
Normal file
94
frontend/src/components/ui/FormTextarea.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface FormTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
/** Full width */
|
||||
fullWidth?: boolean;
|
||||
/** Container class name */
|
||||
containerClassName?: string;
|
||||
/** Show character count */
|
||||
showCharCount?: boolean;
|
||||
/** Max characters for count display */
|
||||
maxChars?: number;
|
||||
}
|
||||
|
||||
export const FormTextarea = forwardRef<HTMLTextAreaElement, FormTextareaProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
fullWidth = true,
|
||||
containerClassName = '',
|
||||
className = '',
|
||||
id,
|
||||
showCharCount = false,
|
||||
maxChars,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const textareaId = id || props.name || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const charCount = typeof value === 'string' ? value.length : 0;
|
||||
|
||||
const baseClasses =
|
||||
'px-3 py-2 border 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 transition-colors resize-y';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
const disabledClasses = props.disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
|
||||
: '';
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<div className={`${containerClassName}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={textareaId}
|
||||
value={value}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${widthClass} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCharCount && (
|
||||
<p className={`text-sm ${maxChars && charCount > maxChars ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{charCount}{maxChars ? `/${maxChars}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormTextarea.displayName = 'FormTextarea';
|
||||
|
||||
export default FormTextarea;
|
||||
74
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
74
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Color of the spinner */
|
||||
color?: 'default' | 'white' | 'brand' | 'blue';
|
||||
/** Optional label to display below spinner */
|
||||
label?: string;
|
||||
/** Center spinner in container */
|
||||
centered?: boolean;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
xl: 'h-12 w-12',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
default: 'text-gray-500 dark:text-gray-400',
|
||||
white: 'text-white',
|
||||
brand: 'text-brand-600 dark:text-brand-400',
|
||||
blue: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
color = 'default',
|
||||
label,
|
||||
centered = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const spinner = (
|
||||
<div className={`flex flex-col items-center gap-2 ${className}`}>
|
||||
<Loader2 className={`animate-spin ${sizeClasses[size]} ${colorClasses[color]}`} />
|
||||
{label && (
|
||||
<span className={`text-sm ${colorClasses[color]}`}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (centered) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
{spinner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return spinner;
|
||||
};
|
||||
|
||||
/** Full page loading state */
|
||||
export const PageLoading: React.FC<{ label?: string }> = ({ label = 'Loading...' }) => (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<LoadingSpinner size="lg" color="brand" label={label} />
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Inline loading indicator */
|
||||
export const InlineLoading: React.FC<{ label?: string }> = ({ label }) => (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
{label && <span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
||||
132
frontend/src/components/ui/Modal.tsx
Normal file
132
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
size?: ModalSize;
|
||||
showCloseButton?: boolean;
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** If true, prevents body scroll when modal is open */
|
||||
preventScroll?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
'3xl': 'max-w-3xl',
|
||||
'4xl': 'max-w-4xl',
|
||||
'5xl': 'max-w-5xl',
|
||||
'6xl': 'max-w-6xl',
|
||||
full: 'max-w-full mx-4',
|
||||
};
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
preventScroll = true,
|
||||
}) => {
|
||||
// Handle escape key
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (closeOnEscape && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[closeOnEscape, onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
if (preventScroll) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
if (preventScroll) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
};
|
||||
}, [isOpen, handleEscape, preventScroll]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div
|
||||
className={`bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ml-auto"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={`flex-1 overflow-y-auto p-6 ${contentClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use portal to render modal at document body level
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
87
frontend/src/components/ui/ModalFooter.tsx
Normal file
87
frontend/src/components/ui/ModalFooter.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
|
||||
|
||||
interface ModalFooterProps {
|
||||
onCancel?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onBack?: () => void;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
backText?: string;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
showBackButton?: boolean;
|
||||
submitVariant?: ButtonVariant;
|
||||
/** Custom content to render instead of default buttons */
|
||||
children?: React.ReactNode;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
success: 'bg-green-600 hover:bg-green-700 text-white',
|
||||
warning: 'bg-amber-600 hover:bg-amber-700 text-white',
|
||||
};
|
||||
|
||||
export const ModalFooter: React.FC<ModalFooterProps> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onBack,
|
||||
submitText = 'Save',
|
||||
cancelText = 'Cancel',
|
||||
backText = 'Back',
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
showBackButton = false,
|
||||
submitVariant = 'primary',
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
if (children) {
|
||||
return <div className={`flex items-center gap-3 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{showBackButton && onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{backText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSubmit && (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isLoading || isDisabled}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${variantClasses[submitVariant]}`}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{submitText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFooter;
|
||||
118
frontend/src/components/ui/StepIndicator.tsx
Normal file
118
frontend/src/components/ui/StepIndicator.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface Step {
|
||||
id: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: Step[];
|
||||
currentStep: number;
|
||||
/** Color for completed/active steps */
|
||||
color?: 'blue' | 'brand' | 'green' | 'purple';
|
||||
/** Show connector lines between steps */
|
||||
showConnectors?: boolean;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
active: 'bg-blue-600 text-white',
|
||||
completed: 'bg-blue-600 text-white',
|
||||
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
textActive: 'text-blue-600 dark:text-blue-400',
|
||||
textPending: 'text-gray-400',
|
||||
connector: 'bg-blue-600',
|
||||
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
||||
},
|
||||
brand: {
|
||||
active: 'bg-brand-600 text-white',
|
||||
completed: 'bg-brand-600 text-white',
|
||||
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
textActive: 'text-brand-600 dark:text-brand-400',
|
||||
textPending: 'text-gray-400',
|
||||
connector: 'bg-brand-600',
|
||||
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
||||
},
|
||||
green: {
|
||||
active: 'bg-green-600 text-white',
|
||||
completed: 'bg-green-600 text-white',
|
||||
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
textActive: 'text-green-600 dark:text-green-400',
|
||||
textPending: 'text-gray-400',
|
||||
connector: 'bg-green-600',
|
||||
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
||||
},
|
||||
purple: {
|
||||
active: 'bg-purple-600 text-white',
|
||||
completed: 'bg-purple-600 text-white',
|
||||
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
textActive: 'text-purple-600 dark:text-purple-400',
|
||||
textPending: 'text-gray-400',
|
||||
connector: 'bg-purple-600',
|
||||
connectorPending: 'bg-gray-200 dark:bg-gray-700',
|
||||
},
|
||||
};
|
||||
|
||||
export const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
steps,
|
||||
currentStep,
|
||||
color = 'blue',
|
||||
showConnectors = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const colors = colorClasses[color];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isPending = stepNumber > currentStep;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Step circle */}
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm transition-colors ${
|
||||
isCompleted
|
||||
? colors.completed
|
||||
: isActive
|
||||
? colors.active
|
||||
: colors.pending
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <Check size={16} /> : stepNumber}
|
||||
</div>
|
||||
|
||||
{/* Step label */}
|
||||
<span
|
||||
className={`font-medium text-sm ${
|
||||
isActive || isCompleted ? colors.textActive : colors.textPending
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
{showConnectors && index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-16 h-0.5 mx-4 ${
|
||||
stepNumber < currentStep ? colors.connector : colors.connectorPending
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepIndicator;
|
||||
150
frontend/src/components/ui/TabGroup.tsx
Normal file
150
frontend/src/components/ui/TabGroup.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabGroupProps {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
onChange: (tabId: string) => void;
|
||||
/** Visual variant */
|
||||
variant?: 'default' | 'pills' | 'underline';
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Full width tabs */
|
||||
fullWidth?: boolean;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Color for active state */
|
||||
activeColor?: 'blue' | 'purple' | 'green' | 'brand';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
};
|
||||
|
||||
const activeColorClasses = {
|
||||
blue: {
|
||||
active: 'bg-blue-600 text-white',
|
||||
pills: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
underline: 'border-blue-600 text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
purple: {
|
||||
active: 'bg-purple-600 text-white',
|
||||
pills: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
underline: 'border-purple-600 text-purple-600 dark:text-purple-400',
|
||||
},
|
||||
green: {
|
||||
active: 'bg-green-600 text-white',
|
||||
pills: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
underline: 'border-green-600 text-green-600 dark:text-green-400',
|
||||
},
|
||||
brand: {
|
||||
active: 'bg-brand-600 text-white',
|
||||
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
|
||||
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
|
||||
},
|
||||
};
|
||||
|
||||
export const TabGroup: React.FC<TabGroupProps> = ({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
className = '',
|
||||
activeColor = 'blue',
|
||||
}) => {
|
||||
const colorClasses = activeColorClasses[activeColor];
|
||||
|
||||
if (variant === 'underline') {
|
||||
return (
|
||||
<div className={`flex border-b border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium border-b-2 -mb-px transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
isActive
|
||||
? colorClasses.underline
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
|
||||
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'pills') {
|
||||
return (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium rounded-full transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
isActive
|
||||
? colorClasses.pills
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant - segmented control style
|
||||
return (
|
||||
<div
|
||||
className={`flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => !tab.disabled && onChange(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={`${sizeClasses[size]} font-medium transition-colors ${fullWidth ? 'flex-1' : ''} ${
|
||||
isActive
|
||||
? colorClasses.active
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabGroup;
|
||||
12
frontend/src/components/ui/UnfinishedBadge.tsx
Normal file
12
frontend/src/components/ui/UnfinishedBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import Badge from './Badge';
|
||||
|
||||
export const UnfinishedBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="warning" size="sm" pill>
|
||||
WIP
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnfinishedBadge;
|
||||
105
frontend/src/components/ui/__tests__/Alert.test.tsx
Normal file
105
frontend/src/components/ui/__tests__/Alert.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Alert, ErrorMessage, SuccessMessage, WarningMessage, InfoMessage } from '../Alert';
|
||||
|
||||
describe('Alert', () => {
|
||||
it('renders message', () => {
|
||||
render(<Alert variant="info" message="Test message" />);
|
||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title when provided', () => {
|
||||
render(<Alert variant="info" message="Message" title="Title" />);
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders message as ReactNode', () => {
|
||||
render(
|
||||
<Alert
|
||||
variant="info"
|
||||
message={<span data-testid="custom">Custom content</span>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has alert role', () => {
|
||||
render(<Alert variant="info" message="Test" />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error variant', () => {
|
||||
render(<Alert variant="error" message="Error" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-red-50');
|
||||
});
|
||||
|
||||
it('renders success variant', () => {
|
||||
render(<Alert variant="success" message="Success" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-green-50');
|
||||
});
|
||||
|
||||
it('renders warning variant', () => {
|
||||
render(<Alert variant="warning" message="Warning" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-amber-50');
|
||||
});
|
||||
|
||||
it('renders info variant', () => {
|
||||
render(<Alert variant="info" message="Info" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-blue-50');
|
||||
});
|
||||
|
||||
it('shows dismiss button when onDismiss is provided', () => {
|
||||
const handleDismiss = vi.fn();
|
||||
render(<Alert variant="info" message="Test" onDismiss={handleDismiss} />);
|
||||
expect(screen.getByLabelText('Dismiss')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDismiss when dismiss button clicked', () => {
|
||||
const handleDismiss = vi.fn();
|
||||
render(<Alert variant="info" message="Test" onDismiss={handleDismiss} />);
|
||||
fireEvent.click(screen.getByLabelText('Dismiss'));
|
||||
expect(handleDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show dismiss button without onDismiss', () => {
|
||||
render(<Alert variant="info" message="Test" />);
|
||||
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Alert variant="info" message="Test" className="custom-class" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('applies compact style', () => {
|
||||
render(<Alert variant="info" message="Test" compact />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('p-2');
|
||||
});
|
||||
|
||||
it('applies regular padding without compact', () => {
|
||||
render(<Alert variant="info" message="Test" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('p-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Convenience components', () => {
|
||||
it('ErrorMessage renders error variant', () => {
|
||||
render(<ErrorMessage message="Error" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-red-50');
|
||||
});
|
||||
|
||||
it('SuccessMessage renders success variant', () => {
|
||||
render(<SuccessMessage message="Success" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-green-50');
|
||||
});
|
||||
|
||||
it('WarningMessage renders warning variant', () => {
|
||||
render(<WarningMessage message="Warning" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-amber-50');
|
||||
});
|
||||
|
||||
it('InfoMessage renders info variant', () => {
|
||||
render(<InfoMessage message="Info" />);
|
||||
expect(screen.getByRole('alert')).toHaveClass('bg-blue-50');
|
||||
});
|
||||
});
|
||||
76
frontend/src/components/ui/__tests__/Badge.test.tsx
Normal file
76
frontend/src/components/ui/__tests__/Badge.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Badge } from '../Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders children', () => {
|
||||
render(<Badge>Test</Badge>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default variant', () => {
|
||||
render(<Badge>Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-gray-100');
|
||||
});
|
||||
|
||||
it('renders primary variant', () => {
|
||||
render(<Badge variant="primary">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-brand-100');
|
||||
});
|
||||
|
||||
it('renders success variant', () => {
|
||||
render(<Badge variant="success">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-green-100');
|
||||
});
|
||||
|
||||
it('renders warning variant', () => {
|
||||
render(<Badge variant="warning">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-amber-100');
|
||||
});
|
||||
|
||||
it('renders danger variant', () => {
|
||||
render(<Badge variant="danger">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-red-100');
|
||||
});
|
||||
|
||||
it('renders info variant', () => {
|
||||
render(<Badge variant="info">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-blue-100');
|
||||
});
|
||||
|
||||
it('applies small size', () => {
|
||||
render(<Badge size="sm">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('applies medium size', () => {
|
||||
render(<Badge size="md">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('applies large size', () => {
|
||||
render(<Badge size="lg">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('text-sm');
|
||||
});
|
||||
|
||||
it('applies pill style', () => {
|
||||
render(<Badge pill>Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('rounded-full');
|
||||
});
|
||||
|
||||
it('applies rounded style by default', () => {
|
||||
render(<Badge>Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('rounded');
|
||||
});
|
||||
|
||||
it('renders dot indicator', () => {
|
||||
const { container } = render(<Badge dot>Test</Badge>);
|
||||
const dot = container.querySelector('.rounded-full');
|
||||
expect(dot).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Badge className="custom-class">Test</Badge>);
|
||||
expect(screen.getByText('Test').closest('span')).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
125
frontend/src/components/ui/__tests__/Button.test.tsx
Normal file
125
frontend/src/components/ui/__tests__/Button.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Button, SubmitButton } from '../Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders children', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click me</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Click me</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled when loading', () => {
|
||||
render(<Button isLoading>Click me</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows loading spinner when loading', () => {
|
||||
render(<Button isLoading>Click me</Button>);
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading text when loading', () => {
|
||||
render(<Button isLoading loadingText="Loading...">Click me</Button>);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies primary variant by default', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-brand-600');
|
||||
});
|
||||
|
||||
it('applies secondary variant', () => {
|
||||
render(<Button variant="secondary">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-gray-600');
|
||||
});
|
||||
|
||||
it('applies danger variant', () => {
|
||||
render(<Button variant="danger">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-red-600');
|
||||
});
|
||||
|
||||
it('applies success variant', () => {
|
||||
render(<Button variant="success">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-green-600');
|
||||
});
|
||||
|
||||
it('applies warning variant', () => {
|
||||
render(<Button variant="warning">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-amber-600');
|
||||
});
|
||||
|
||||
it('applies outline variant', () => {
|
||||
render(<Button variant="outline">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('applies ghost variant', () => {
|
||||
render(<Button variant="ghost">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('applies size classes', () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('px-3');
|
||||
});
|
||||
|
||||
it('applies full width', () => {
|
||||
render(<Button fullWidth>Full Width</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('renders left icon', () => {
|
||||
render(<Button leftIcon={<span data-testid="left-icon">L</span>}>With Icon</Button>);
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders right icon', () => {
|
||||
render(<Button rightIcon={<span data-testid="right-icon">R</span>}>With Icon</Button>);
|
||||
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Click me</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubmitButton', () => {
|
||||
it('renders submit text by default', () => {
|
||||
render(<SubmitButton />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has type submit', () => {
|
||||
render(<SubmitButton />);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('renders custom submit text', () => {
|
||||
render(<SubmitButton submitText="Submit" />);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children over submitText', () => {
|
||||
render(<SubmitButton submitText="Submit">Custom</SubmitButton>);
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading text when loading', () => {
|
||||
render(<SubmitButton isLoading />);
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
165
frontend/src/components/ui/__tests__/Card.test.tsx
Normal file
165
frontend/src/components/ui/__tests__/Card.test.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Card, CardHeader, CardBody, CardFooter } from '../Card';
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
render(<Card>Card content</Card>);
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies base card styling', () => {
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('bg-white', 'rounded-lg', 'shadow-sm');
|
||||
});
|
||||
|
||||
it('applies bordered style by default', () => {
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('border');
|
||||
});
|
||||
|
||||
it('can remove border', () => {
|
||||
const { container } = render(<Card bordered={false}>Content</Card>);
|
||||
expect(container.firstChild).not.toHaveClass('border');
|
||||
});
|
||||
|
||||
it('applies medium padding by default', () => {
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('p-4');
|
||||
});
|
||||
|
||||
it('applies no padding', () => {
|
||||
const { container } = render(<Card padding="none">Content</Card>);
|
||||
expect(container.firstChild).not.toHaveClass('p-3', 'p-4', 'p-6');
|
||||
});
|
||||
|
||||
it('applies small padding', () => {
|
||||
const { container } = render(<Card padding="sm">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('p-3');
|
||||
});
|
||||
|
||||
it('applies large padding', () => {
|
||||
const { container } = render(<Card padding="lg">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('p-6');
|
||||
});
|
||||
|
||||
it('applies hoverable styling when hoverable', () => {
|
||||
const { container } = render(<Card hoverable>Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('hover:shadow-md', 'cursor-pointer');
|
||||
});
|
||||
|
||||
it('is not hoverable by default', () => {
|
||||
const { container } = render(<Card>Content</Card>);
|
||||
expect(container.firstChild).not.toHaveClass('cursor-pointer');
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Card onClick={handleClick}>Content</Card>);
|
||||
fireEvent.click(screen.getByText('Content'));
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has button role when clickable', () => {
|
||||
render(<Card onClick={() => {}}>Content</Card>);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has tabIndex when clickable', () => {
|
||||
render(<Card onClick={() => {}}>Content</Card>);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('tabIndex', '0');
|
||||
});
|
||||
|
||||
it('does not have button role when not clickable', () => {
|
||||
render(<Card>Content</Card>);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<Card className="custom-class">Content</Card>);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardHeader', () => {
|
||||
it('renders children', () => {
|
||||
render(<CardHeader>Header content</CardHeader>);
|
||||
expect(screen.getByText('Header content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies header styling', () => {
|
||||
const { container } = render(<CardHeader>Header</CardHeader>);
|
||||
expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-between');
|
||||
});
|
||||
|
||||
it('applies border bottom', () => {
|
||||
const { container } = render(<CardHeader>Header</CardHeader>);
|
||||
expect(container.firstChild).toHaveClass('border-b');
|
||||
});
|
||||
|
||||
it('renders actions when provided', () => {
|
||||
render(<CardHeader actions={<button>Action</button>}>Header</CardHeader>);
|
||||
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<CardHeader className="custom-class">Header</CardHeader>);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('applies font styling to header text', () => {
|
||||
const { container } = render(<CardHeader>Header</CardHeader>);
|
||||
const headerText = container.querySelector('.font-semibold');
|
||||
expect(headerText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardBody', () => {
|
||||
it('renders children', () => {
|
||||
render(<CardBody>Body content</CardBody>);
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies body styling', () => {
|
||||
const { container } = render(<CardBody>Body</CardBody>);
|
||||
expect(container.firstChild).toHaveClass('py-4');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<CardBody className="custom-class">Body</CardBody>);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardFooter', () => {
|
||||
it('renders children', () => {
|
||||
render(<CardFooter>Footer content</CardFooter>);
|
||||
expect(screen.getByText('Footer content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies footer styling', () => {
|
||||
const { container } = render(<CardFooter>Footer</CardFooter>);
|
||||
expect(container.firstChild).toHaveClass('pt-4', 'border-t');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<CardFooter className="custom-class">Footer</CardFooter>);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card composition', () => {
|
||||
it('renders complete card with all parts', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader actions={<button>Edit</button>}>Title</CardHeader>
|
||||
<CardBody>Main content here</CardBody>
|
||||
<CardFooter>Footer content</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Main content here')).toBeInTheDocument();
|
||||
expect(screen.getByText('Footer content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user