Compare commits
98 Commits
5cef01ad0d
...
feature/si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c0d71aa0 | ||
|
|
384fe0fd86 | ||
|
|
4afcaa2b0d | ||
|
|
8c52d6a275 | ||
|
|
18c9a69d75 | ||
|
|
30ec150d90 | ||
|
|
ba2c656243 | ||
|
|
485f86086b | ||
|
|
2f6ea82114 | ||
|
|
507222316c | ||
|
|
c5c108c76f | ||
|
|
90fa628cb5 | ||
|
|
7f389830f8 | ||
|
|
30909f3268 | ||
|
|
df45a6f5d7 | ||
|
|
156ad09232 | ||
|
|
8dc2248f1f | ||
|
|
c220612214 | ||
|
|
33137289ef | ||
|
|
b2be35bdfa | ||
|
|
a4b23e44b6 | ||
|
|
67ce2c433c | ||
|
|
1391374d45 | ||
|
|
8440ac945a | ||
|
|
f4332153f4 | ||
|
|
b9e90e6f46 | ||
|
|
1af79cc019 | ||
|
|
156cc2676d | ||
|
|
897a336d0b | ||
|
|
410b46a896 | ||
|
|
01020861c7 | ||
|
|
61882b300f | ||
|
|
46b154e957 | ||
|
|
023ea7f020 | ||
|
|
35f4301fe1 | ||
|
|
6feaa8dda5 | ||
|
|
f084e33621 | ||
|
|
db0165dc5e | ||
|
|
af891d7e8f | ||
|
|
7ef255a5f1 | ||
|
|
29e99631c9 | ||
|
|
2d7c1dcd27 | ||
|
|
8d0cc1e90a | ||
|
|
cf91bae24f | ||
|
|
c7308ad167 | ||
|
|
7da5d55831 | ||
|
|
3bc8167649 | ||
|
|
b0512a660c | ||
|
|
65faaae864 | ||
|
|
dbe91ec2ff | ||
|
|
a2f74ee769 | ||
|
|
9073970189 | ||
|
|
6554e62d30 | ||
|
|
bd6d9144ce | ||
|
|
ad04e5f6ff | ||
|
|
460bf200d0 | ||
|
|
3e8634b370 | ||
|
|
bc094f2f80 | ||
|
|
c7f241b30a | ||
|
|
902582f4ba | ||
|
|
7b18637b1e | ||
|
|
3a1b2f2dd8 | ||
|
|
88b54ef9e4 | ||
|
|
5cdbc19517 | ||
|
|
f3a0f1f07a | ||
|
|
f3951295ac | ||
|
|
9cbf19ed1b | ||
|
|
88c74398e4 | ||
|
|
86947ab206 | ||
|
|
7cc013eaf2 | ||
|
|
a723d784cd | ||
|
|
13441d88fc | ||
|
|
b20fa5cfd8 | ||
|
|
093f6d9a62 | ||
|
|
5bf2fc5319 | ||
|
|
33e4b6b9b5 | ||
|
|
434f874963 | ||
|
|
0d3c97ea5f | ||
|
|
567fe0604a | ||
|
|
5244e16279 | ||
|
|
55cb97ca0d | ||
|
|
a170d6134b | ||
|
|
d2c4cbe183 | ||
|
|
47f1a4d7b4 | ||
|
|
b455be0ac6 | ||
|
|
abf67a36ed | ||
|
|
4f515c3710 | ||
|
|
fd751f02f8 | ||
|
|
04bb9e3c14 | ||
|
|
39a376b39b | ||
|
|
85c4b835fd | ||
|
|
bed0ba9304 | ||
|
|
dcb14503a2 | ||
|
|
9444e26924 | ||
|
|
445b2bb3fc | ||
|
|
baffe7e577 | ||
|
|
5aa49399d0 | ||
|
|
11bb83a85d |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Test coverage reports (generated)
|
||||
frontend/coverage/
|
||||
450
CLAUDE.md
450
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:
|
||||
@@ -61,14 +224,293 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
|
||||
| `frontend/src/api/client.ts` | Axios API client |
|
||||
| `frontend/src/types.ts` | TypeScript interfaces |
|
||||
| `frontend/src/i18n/locales/en.json` | Translations |
|
||||
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
|
||||
|
||||
## Key Django Apps
|
||||
## Timezone Architecture (CRITICAL)
|
||||
|
||||
All date/time handling follows this architecture to ensure consistency across timezones.
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Database**: All times stored in UTC
|
||||
2. **API Communication**: Always use UTC (both directions)
|
||||
3. **API Responses**: Include `business_timezone` field
|
||||
4. **Frontend Display**: Convert UTC based on `business_timezone`
|
||||
- If `business_timezone` is set → display in that timezone
|
||||
- If `business_timezone` is null/blank → display in user's local timezone
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
|
||||
↓
|
||||
Convert to UTC: "2024-12-08T19:00:00Z"
|
||||
↓
|
||||
Send to API (always UTC)
|
||||
↓
|
||||
DATABASE (stores UTC)
|
||||
↓
|
||||
API RESPONSE:
|
||||
{
|
||||
"start_time": "2024-12-08T19:00:00Z", // Always UTC
|
||||
"business_timezone": "America/Denver" // IANA timezone (or null for local)
|
||||
}
|
||||
↓
|
||||
FRONTEND CONVERTS:
|
||||
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
|
||||
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
|
||||
```
|
||||
|
||||
### Frontend Helper Functions
|
||||
|
||||
Located in `frontend/src/utils/dateUtils.ts`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
toUTC,
|
||||
fromUTC,
|
||||
formatForDisplay,
|
||||
formatDateForDisplay,
|
||||
getDisplayTimezone,
|
||||
} from '../utils/dateUtils';
|
||||
|
||||
// SENDING TO API - Always convert to UTC
|
||||
const apiPayload = {
|
||||
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
|
||||
};
|
||||
|
||||
// RECEIVING FROM API - Convert for display
|
||||
const displayTime = formatForDisplay(
|
||||
response.start_time, // UTC from API
|
||||
response.business_timezone // "America/Denver" or null
|
||||
);
|
||||
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
|
||||
|
||||
// DATE-ONLY fields (time blocks)
|
||||
const displayDate = formatDateForDisplay(
|
||||
response.start_date,
|
||||
response.business_timezone
|
||||
);
|
||||
```
|
||||
|
||||
### API Response Requirements
|
||||
|
||||
All endpoints returning date/time data MUST include:
|
||||
|
||||
```python
|
||||
# In serializers or views
|
||||
{
|
||||
"start_time": "2024-12-08T19:00:00Z",
|
||||
"business_timezone": business.timezone, # "America/Denver" or None
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Serializer Mixin
|
||||
|
||||
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
|
||||
|
||||
```python
|
||||
from core.mixins import TimezoneSerializerMixin
|
||||
|
||||
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
'id', 'start_time', 'end_time',
|
||||
# ... other fields ...
|
||||
'business_timezone', # Provided by mixin
|
||||
]
|
||||
read_only_fields = ['business_timezone']
|
||||
```
|
||||
|
||||
The mixin automatically retrieves the timezone from the tenant context.
|
||||
- Returns the IANA timezone string if set (e.g., "America/Denver")
|
||||
- Returns `null` if not set (frontend uses user's local timezone)
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
```typescript
|
||||
// BAD - Uses browser local time, not UTC
|
||||
date.toISOString().split('T')[0]
|
||||
|
||||
// BAD - Doesn't respect business timezone setting
|
||||
new Date(utcString).toLocaleString()
|
||||
|
||||
// GOOD - Use helper functions
|
||||
toUTC(date) // For API requests
|
||||
formatForDisplay(utcString, businessTimezone) // For displaying
|
||||
```
|
||||
|
||||
## Django App Organization (Domain-Based)
|
||||
|
||||
Apps are organized into domain packages under `smoothschedule/smoothschedule/`:
|
||||
|
||||
### Identity Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
||||
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
|
||||
| `users` | `identity/users/` | User model, authentication, MFA |
|
||||
|
||||
### Scheduling Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `schedule` | `scheduling/schedule/` | Resources, Events, Services, Participants |
|
||||
| `contracts` | `scheduling/contracts/` | Contract/e-signature system |
|
||||
| `analytics` | `scheduling/analytics/` | Business analytics and reporting |
|
||||
|
||||
### Communication Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `notifications` | `communication/notifications/` | Notification system |
|
||||
| `credits` | `communication/credits/` | SMS/calling credits |
|
||||
| `mobile` | `communication/mobile/` | Field employee mobile app |
|
||||
| `messaging` | `communication/messaging/` | Email templates and messaging |
|
||||
|
||||
### Commerce Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `payments` | `commerce/payments/` | Stripe Connect payments bridge |
|
||||
| `tickets` | `commerce/tickets/` | Support ticket system |
|
||||
|
||||
### Platform Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `admin` | `platform/admin/` | Platform administration, subscriptions |
|
||||
| `api` | `platform/api/` | Public API v1 for third-party integrations |
|
||||
|
||||
## Core Mixins & Base Classes
|
||||
|
||||
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
|
||||
|
||||
### Permission Classes
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||
|
||||
class MyViewSet(ModelViewSet):
|
||||
# Block write operations for staff (GET allowed)
|
||||
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
|
||||
|
||||
# Block ALL operations for staff
|
||||
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||
|
||||
# Block list/create/update/delete but allow retrieve
|
||||
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
||||
```
|
||||
|
||||
#### Per-User Permission Overrides
|
||||
|
||||
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
|
||||
Permission keys are auto-derived from the view's basename or model name:
|
||||
|
||||
| Permission Class | Auto-derived Key | Example |
|
||||
|-----------------|------------------|---------|
|
||||
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
|
||||
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
|
||||
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
|
||||
|
||||
**Current ViewSet permission keys:**
|
||||
|
||||
| ViewSet | Permission Class | Override Key |
|
||||
|---------|-----------------|--------------|
|
||||
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
|
||||
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
|
||||
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
|
||||
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
|
||||
|
||||
**Granting a specific staff member access:**
|
||||
```bash
|
||||
# Open Django shell
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
# Find the staff member
|
||||
staff = User.objects.get(email='john@example.com')
|
||||
|
||||
# Grant read access to resources
|
||||
staff.permissions['can_access_resources'] = True
|
||||
staff.save()
|
||||
|
||||
# Or grant list access to customers (but not full CRUD)
|
||||
staff.permissions['can_list_customers'] = True
|
||||
staff.save()
|
||||
```
|
||||
|
||||
**Custom permission keys (optional):**
|
||||
```python
|
||||
class ResourceViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||
# Override the auto-derived key
|
||||
staff_access_permission_key = 'can_manage_equipment'
|
||||
```
|
||||
|
||||
Then grant via: `staff.permissions['can_manage_equipment'] = True`
|
||||
|
||||
### QuerySet Mixins
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||
|
||||
# For tenant-scoped models (automatic django-tenants filtering)
|
||||
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||
queryset = Resource.objects.all()
|
||||
deny_staff_queryset = True # Optional: also filter staff at queryset level
|
||||
|
||||
def filter_queryset_for_tenant(self, queryset):
|
||||
# Override for custom filtering
|
||||
return queryset.filter(is_active=True)
|
||||
|
||||
# For User model (shared schema, needs explicit tenant filter)
|
||||
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
||||
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||
```
|
||||
|
||||
### Feature Permission Mixins
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||
|
||||
# Checks can_use_plugins feature on list/retrieve/create
|
||||
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||
pass
|
||||
|
||||
# Checks both can_use_plugins AND can_use_tasks
|
||||
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
|
||||
pass
|
||||
```
|
||||
|
||||
### Base API Views (for non-ViewSet views)
|
||||
|
||||
```python
|
||||
from rest_framework.views import APIView
|
||||
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
|
||||
# Optional tenant - use self.get_tenant()
|
||||
class MyView(TenantAPIView, APIView):
|
||||
def get(self, request):
|
||||
tenant = self.get_tenant() # May be None
|
||||
return self.success_response({'data': 'value'})
|
||||
# or: return self.error_response('Something went wrong', status_code=400)
|
||||
|
||||
# Required tenant - self.tenant always available
|
||||
class MyTenantView(TenantRequiredAPIView, APIView):
|
||||
def get(self, request):
|
||||
# self.tenant is guaranteed to exist (returns 400 if missing)
|
||||
return Response({'name': self.tenant.name})
|
||||
```
|
||||
|
||||
### Helper Methods Available
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `self.get_tenant()` | Get tenant from request (may be None) |
|
||||
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
|
||||
| `self.error_response(msg, status_code)` | Standard error response |
|
||||
| `self.success_response(data, status_code)` | Standard success response |
|
||||
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
383
PLAN_APP_REORGANIZATION.md
Normal file
383
PLAN_APP_REORGANIZATION.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Django App Reorganization Plan - Option C (Domain-Based)
|
||||
|
||||
## Overview
|
||||
|
||||
Reorganize Django apps from their current scattered locations into a clean domain-based structure within `smoothschedule/smoothschedule/`.
|
||||
|
||||
**Branch:** `refactor/organize-django-apps`
|
||||
**Risk Level:** Medium-High (migration history must be preserved)
|
||||
**Estimated Parallel Agents:** 6-8
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Current App Locations (Inconsistent)
|
||||
|
||||
| App | Current Location | Registered As |
|
||||
|-----|-----------------|---------------|
|
||||
| core | `smoothschedule/core/` | `"core"` |
|
||||
| schedule | `smoothschedule/schedule/` | `"schedule"` |
|
||||
| payments | `smoothschedule/payments/` | `"payments"` |
|
||||
| platform_admin | `smoothschedule/platform_admin/` | `"platform_admin.apps.PlatformAdminConfig"` |
|
||||
| analytics | `smoothschedule/analytics/` | `"analytics"` |
|
||||
| notifications | `smoothschedule/notifications/` | `"notifications"` |
|
||||
| tickets | `smoothschedule/tickets/` | `"tickets"` |
|
||||
| contracts | `smoothschedule/contracts/` | **NOT REGISTERED** |
|
||||
| communication | `smoothschedule/communication/` | **NOT REGISTERED** |
|
||||
| users | `smoothschedule/smoothschedule/users/` | `"smoothschedule.users"` |
|
||||
| comms_credits | `smoothschedule/smoothschedule/comms_credits/` | `"smoothschedule.comms_credits"` |
|
||||
| field_mobile | `smoothschedule/smoothschedule/field_mobile/` | `"smoothschedule.field_mobile"` |
|
||||
| public_api | `smoothschedule/smoothschedule/public_api/` | `"smoothschedule.public_api"` |
|
||||
|
||||
### Migration Counts by App
|
||||
|
||||
| App | Migrations | Complexity |
|
||||
|-----|------------|------------|
|
||||
| core | 22 | High (Tenant model) |
|
||||
| schedule | 30 | High (main business logic) |
|
||||
| payments | 1 | Low |
|
||||
| platform_admin | 12 | Medium |
|
||||
| users | 10 | Medium |
|
||||
| tickets | 13 | Medium |
|
||||
| contracts | 1 | Low |
|
||||
| notifications | 1 | Low |
|
||||
| comms_credits | 2 | Low |
|
||||
| field_mobile | 1 | Low |
|
||||
| public_api | 3 | Low |
|
||||
| analytics | 0 | None |
|
||||
| communication | 1 | Low |
|
||||
|
||||
---
|
||||
|
||||
## Target Structure (Option C - Domain-Based)
|
||||
|
||||
```
|
||||
smoothschedule/smoothschedule/
|
||||
├── __init__.py
|
||||
│
|
||||
├── identity/ # User & Tenant Management
|
||||
│ ├── __init__.py
|
||||
│ ├── core/ # Multi-tenancy, permissions, OAuth
|
||||
│ │ └── (moved from smoothschedule/core/)
|
||||
│ └── users/ # User model, auth, invitations
|
||||
│ └── (keep at current location, just move parent)
|
||||
│
|
||||
├── scheduling/ # Core Business Logic
|
||||
│ ├── __init__.py
|
||||
│ ├── schedule/ # Resources, Events, Services, Plugins
|
||||
│ │ └── (moved from smoothschedule/schedule/)
|
||||
│ ├── contracts/ # E-signatures, legal documents
|
||||
│ │ └── (moved from smoothschedule/contracts/)
|
||||
│ └── analytics/ # Reporting, dashboards
|
||||
│ └── (moved from smoothschedule/analytics/)
|
||||
│
|
||||
├── communication/ # Messaging & Notifications
|
||||
│ ├── __init__.py
|
||||
│ ├── notifications/ # In-app notifications
|
||||
│ │ └── (moved from smoothschedule/notifications/)
|
||||
│ ├── credits/ # SMS/voice credits (renamed from comms_credits)
|
||||
│ │ └── (moved from smoothschedule/smoothschedule/comms_credits/)
|
||||
│ ├── mobile/ # Field employee app (renamed from field_mobile)
|
||||
│ │ └── (moved from smoothschedule/smoothschedule/field_mobile/)
|
||||
│ └── messaging/ # Twilio conversations (renamed from communication)
|
||||
│ └── (moved from smoothschedule/communication/)
|
||||
│
|
||||
├── commerce/ # Payments & Support
|
||||
│ ├── __init__.py
|
||||
│ ├── payments/ # Stripe Connect, transactions
|
||||
│ │ └── (moved from smoothschedule/payments/)
|
||||
│ └── tickets/ # Support tickets, email integration
|
||||
│ └── (moved from smoothschedule/tickets/)
|
||||
│
|
||||
└── platform/ # Platform Administration
|
||||
├── __init__.py
|
||||
├── admin/ # Platform settings, subscriptions (renamed)
|
||||
│ └── (moved from smoothschedule/platform_admin/)
|
||||
└── api/ # Public API v1 (renamed from public_api)
|
||||
└── (moved from smoothschedule/smoothschedule/public_api/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
### 1. Migration History Preservation
|
||||
|
||||
Django migrations contain the app label in their `dependencies` and `app_label` references. We MUST:
|
||||
|
||||
- **Keep `app_label` unchanged** in each app's `Meta` class
|
||||
- Update `AppConfig.name` to the new dotted path
|
||||
- Django will use the `app_label` (not the path) for migration tracking
|
||||
|
||||
### 2. Foreign Key String References
|
||||
|
||||
Models use string references like `'users.User'` and `'core.Tenant'`. These reference `app_label`, not the module path, so they remain valid.
|
||||
|
||||
### 3. Import Path Updates
|
||||
|
||||
All imports across the codebase must be updated:
|
||||
- `from core.models import Tenant` → `from smoothschedule.identity.core.models import Tenant`
|
||||
- `from schedule.models import Event` → `from smoothschedule.scheduling.schedule.models import Event`
|
||||
|
||||
### 4. URL Configuration
|
||||
|
||||
`config/urls.py` imports views directly - all import paths must be updated.
|
||||
|
||||
### 5. Settings Files
|
||||
|
||||
- `config/settings/base.py` - `LOCAL_APPS`
|
||||
- `config/settings/multitenancy.py` - `SHARED_APPS`, `TENANT_APPS`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Preparation (Serial)
|
||||
|
||||
**Agent 1: Setup & Verification**
|
||||
1. Create all domain package directories with `__init__.py` files
|
||||
2. Verify Docker is running and database is accessible
|
||||
3. Run existing tests to establish baseline
|
||||
4. Create backup of current migration state
|
||||
|
||||
```bash
|
||||
# Create domain packages
|
||||
mkdir -p smoothschedule/smoothschedule/identity
|
||||
mkdir -p smoothschedule/smoothschedule/scheduling
|
||||
mkdir -p smoothschedule/smoothschedule/communication
|
||||
mkdir -p smoothschedule/smoothschedule/commerce
|
||||
mkdir -p smoothschedule/smoothschedule/platform
|
||||
|
||||
# Create __init__.py files
|
||||
touch smoothschedule/smoothschedule/identity/__init__.py
|
||||
touch smoothschedule/smoothschedule/scheduling/__init__.py
|
||||
touch smoothschedule/smoothschedule/communication/__init__.py
|
||||
touch smoothschedule/smoothschedule/commerce/__init__.py
|
||||
touch smoothschedule/smoothschedule/platform/__init__.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Move Apps (Parallel - 5 Agents)
|
||||
|
||||
Each agent handles one domain. For each app move:
|
||||
|
||||
1. **Move directory** to new location
|
||||
2. **Update `apps.py`** - change `name` to new dotted path, keep `label` same
|
||||
3. **Update internal imports** within the app
|
||||
4. **Add explicit `app_label`** to all model Meta classes (if not present)
|
||||
|
||||
#### Agent 2: Identity Domain
|
||||
Move and update:
|
||||
- `smoothschedule/core/` → `smoothschedule/smoothschedule/identity/core/`
|
||||
- `smoothschedule/smoothschedule/users/` → `smoothschedule/smoothschedule/identity/users/`
|
||||
|
||||
**apps.py changes:**
|
||||
```python
|
||||
# identity/core/apps.py
|
||||
class CoreConfig(AppConfig):
|
||||
name = "smoothschedule.identity.core" # NEW
|
||||
label = "core" # KEEP SAME
|
||||
verbose_name = "Core"
|
||||
|
||||
# identity/users/apps.py
|
||||
class UsersConfig(AppConfig):
|
||||
name = "smoothschedule.identity.users" # NEW
|
||||
label = "users" # KEEP SAME
|
||||
```
|
||||
|
||||
#### Agent 3: Scheduling Domain
|
||||
Move and update:
|
||||
- `smoothschedule/schedule/` → `smoothschedule/smoothschedule/scheduling/schedule/`
|
||||
- `smoothschedule/contracts/` → `smoothschedule/smoothschedule/scheduling/contracts/`
|
||||
- `smoothschedule/analytics/` → `smoothschedule/smoothschedule/scheduling/analytics/`
|
||||
|
||||
#### Agent 4: Communication Domain
|
||||
Move and update:
|
||||
- `smoothschedule/notifications/` → `smoothschedule/smoothschedule/communication/notifications/`
|
||||
- `smoothschedule/smoothschedule/comms_credits/` → `smoothschedule/smoothschedule/communication/credits/`
|
||||
- `smoothschedule/smoothschedule/field_mobile/` → `smoothschedule/smoothschedule/communication/mobile/`
|
||||
- `smoothschedule/communication/` → `smoothschedule/smoothschedule/communication/messaging/`
|
||||
|
||||
**Note:** Rename apps for clarity:
|
||||
- `comms_credits` label stays same, path changes
|
||||
- `field_mobile` label stays same, path changes
|
||||
- `communication` label stays same, path changes
|
||||
|
||||
#### Agent 5: Commerce Domain
|
||||
Move and update:
|
||||
- `smoothschedule/payments/` → `smoothschedule/smoothschedule/commerce/payments/`
|
||||
- `smoothschedule/tickets/` → `smoothschedule/smoothschedule/commerce/tickets/`
|
||||
|
||||
#### Agent 6: Platform Domain
|
||||
Move and update:
|
||||
- `smoothschedule/platform_admin/` → `smoothschedule/smoothschedule/platform/admin/`
|
||||
- `smoothschedule/smoothschedule/public_api/` → `smoothschedule/smoothschedule/platform/api/`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Update Settings (Serial)
|
||||
|
||||
**Agent 7: Settings Configuration**
|
||||
|
||||
Update `config/settings/base.py`:
|
||||
```python
|
||||
LOCAL_APPS = [
|
||||
# Identity
|
||||
"smoothschedule.identity.users",
|
||||
"smoothschedule.identity.core",
|
||||
|
||||
# Scheduling
|
||||
"smoothschedule.scheduling.schedule",
|
||||
"smoothschedule.scheduling.contracts",
|
||||
"smoothschedule.scheduling.analytics",
|
||||
|
||||
# Communication
|
||||
"smoothschedule.communication.notifications",
|
||||
"smoothschedule.communication.credits",
|
||||
"smoothschedule.communication.mobile",
|
||||
"smoothschedule.communication.messaging",
|
||||
|
||||
# Commerce
|
||||
"smoothschedule.commerce.payments",
|
||||
"smoothschedule.commerce.tickets",
|
||||
|
||||
# Platform
|
||||
"smoothschedule.platform.admin",
|
||||
"smoothschedule.platform.api",
|
||||
]
|
||||
```
|
||||
|
||||
Update `config/settings/multitenancy.py`:
|
||||
```python
|
||||
SHARED_APPS = [
|
||||
'django_tenants',
|
||||
'smoothschedule.identity.core',
|
||||
'smoothschedule.platform.admin',
|
||||
# ... rest of shared apps with new paths
|
||||
]
|
||||
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'smoothschedule.scheduling.schedule',
|
||||
'smoothschedule.commerce.payments',
|
||||
'smoothschedule.scheduling.contracts',
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Update All Import Paths (Parallel - Multiple Agents)
|
||||
|
||||
**This is the largest task.** Each agent handles specific import patterns:
|
||||
|
||||
#### Agent 8: Core Imports
|
||||
Find and replace across entire codebase:
|
||||
- `from core.models import` → `from smoothschedule.identity.core.models import`
|
||||
- `from core.` → `from smoothschedule.identity.core.`
|
||||
- `import core` → `import smoothschedule.identity.core as core`
|
||||
|
||||
#### Agent 9: Schedule Imports
|
||||
- `from schedule.models import` → `from smoothschedule.scheduling.schedule.models import`
|
||||
- `from schedule.` → `from smoothschedule.scheduling.schedule.`
|
||||
|
||||
#### Agent 10: Users/Auth Imports
|
||||
- `from smoothschedule.users.` → `from smoothschedule.identity.users.`
|
||||
- `from users.` → `from smoothschedule.identity.users.`
|
||||
|
||||
#### Agent 11: Other App Imports
|
||||
Handle remaining apps:
|
||||
- payments, tickets, notifications, contracts, analytics
|
||||
- platform_admin, public_api, comms_credits, field_mobile, communication
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: URL Configuration Updates (Serial)
|
||||
|
||||
**Agent 12: URL Updates**
|
||||
|
||||
Update `config/urls.py` with new import paths:
|
||||
```python
|
||||
# Old
|
||||
from schedule.views import ResourceViewSet, EventViewSet
|
||||
from core.api_views import business_current
|
||||
|
||||
# New
|
||||
from smoothschedule.scheduling.schedule.views import ResourceViewSet, EventViewSet
|
||||
from smoothschedule.identity.core.api_views import business_current
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Cleanup & Verification (Serial)
|
||||
|
||||
**Agent 13: Cleanup**
|
||||
1. Remove old empty directories at top level
|
||||
2. Remove deprecated `smoothschedule/smoothschedule/schedule/` directory
|
||||
3. Update `CLAUDE.md` documentation
|
||||
4. Update any remaining references
|
||||
|
||||
**Agent 14: Verification**
|
||||
1. Run `docker compose exec django python manage.py check`
|
||||
2. Run `docker compose exec django python manage.py makemigrations --check`
|
||||
3. Run `docker compose exec django python manage.py migrate --check`
|
||||
4. Run test suite
|
||||
5. Manual smoke test of key endpoints
|
||||
|
||||
---
|
||||
|
||||
## App Label Mapping Reference
|
||||
|
||||
| Old Import Path | New Import Path | app_label (unchanged) |
|
||||
|----------------|-----------------|----------------------|
|
||||
| `core` | `smoothschedule.identity.core` | `core` |
|
||||
| `smoothschedule.users` | `smoothschedule.identity.users` | `users` |
|
||||
| `schedule` | `smoothschedule.scheduling.schedule` | `schedule` |
|
||||
| `contracts` | `smoothschedule.scheduling.contracts` | `contracts` |
|
||||
| `analytics` | `smoothschedule.scheduling.analytics` | `analytics` |
|
||||
| `notifications` | `smoothschedule.communication.notifications` | `notifications` |
|
||||
| `smoothschedule.comms_credits` | `smoothschedule.communication.credits` | `comms_credits` |
|
||||
| `smoothschedule.field_mobile` | `smoothschedule.communication.mobile` | `field_mobile` |
|
||||
| `communication` | `smoothschedule.communication.messaging` | `communication` |
|
||||
| `payments` | `smoothschedule.commerce.payments` | `payments` |
|
||||
| `tickets` | `smoothschedule.commerce.tickets` | `tickets` |
|
||||
| `platform_admin` | `smoothschedule.platform.admin` | `platform_admin` |
|
||||
| `smoothschedule.public_api` | `smoothschedule.platform.api` | `public_api` |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are encountered:
|
||||
|
||||
1. **Git Reset:** `git checkout main` and delete branch
|
||||
2. **Database:** No migration changes, database remains intact
|
||||
3. **Docker:** Rebuild containers if needed
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All apps moved to domain-based structure
|
||||
- [ ] `python manage.py check` passes
|
||||
- [ ] `python manage.py makemigrations --check` shows no changes
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Frontend can communicate with API
|
||||
- [ ] Mobile app can communicate with API
|
||||
- [ ] CLAUDE.md updated with new structure
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
```
|
||||
Phase 1 (Serial): Agent 1 - Setup
|
||||
Phase 2 (Parallel): Agents 2-6 - Move apps by domain
|
||||
Phase 3 (Serial): Agent 7 - Update settings
|
||||
Phase 4 (Parallel): Agents 8-11 - Update imports
|
||||
Phase 5 (Serial): Agent 12 - URL updates
|
||||
Phase 6 (Serial): Agents 13-14 - Cleanup & verify
|
||||
```
|
||||
|
||||
**Total Agents:** 14 (8 can run in parallel at peak)
|
||||
179
PLAN_HELP_DOCS.md
Normal file
179
PLAN_HELP_DOCS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Help Documentation Implementation Plan
|
||||
|
||||
## Overview
|
||||
This plan covers creating comprehensive help documentation for the SmoothSchedule business dashboard, adding contextual help buttons to each page, and creating a monolithic help document.
|
||||
|
||||
## Phase 1: Create Plugin Page First (User Request)
|
||||
|
||||
### Task 1.1: Create CreatePlugin.tsx Page
|
||||
- Create `/frontend/src/pages/CreatePlugin.tsx`
|
||||
- Features:
|
||||
- Name, description, short description fields
|
||||
- Category dropdown (EMAIL, REPORTS, CUSTOMER, BOOKING, INTEGRATION, AUTOMATION, OTHER)
|
||||
- Plugin code editor with syntax highlighting (using same Prism setup as HelpPluginDocs)
|
||||
- Template variables preview (auto-extracted from code)
|
||||
- Version field (default 1.0.0)
|
||||
- Logo URL field (optional)
|
||||
- Save as Private / Submit to Marketplace options
|
||||
- Visibility selector (PRIVATE, PUBLIC)
|
||||
- Uses API endpoint: `POST /api/plugin-templates/`
|
||||
- Plan feature gate: `can_create_plugins`
|
||||
|
||||
### Task 1.2: Add Route for CreatePlugin
|
||||
- Add lazy import: `const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin'));`
|
||||
- Add route: `/plugins/create` pointing to CreatePlugin component
|
||||
|
||||
## Phase 2: Create Reusable HelpButton Component
|
||||
|
||||
### Task 2.1: Create HelpButton Component
|
||||
- Create `/frontend/src/components/HelpButton.tsx`
|
||||
- Props: `helpPath: string` (route to help page)
|
||||
- Renders: HelpCircle icon button at fixed position (top-right of page)
|
||||
- Styling: Circular button with question mark icon, tooltip on hover
|
||||
- Uses Link from react-router-dom to navigate to help page
|
||||
|
||||
## Phase 3: Create Individual Help Pages
|
||||
|
||||
### 3.1 Core Pages Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| Dashboard | HelpDashboard.tsx | /help/dashboard |
|
||||
| Scheduler | HelpScheduler.tsx | /help/scheduler |
|
||||
| Tasks | HelpTasks.tsx | /help/tasks |
|
||||
|
||||
### 3.2 Manage Section Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| Customers | HelpCustomers.tsx | /help/customers |
|
||||
| Services | HelpServices.tsx | /help/services |
|
||||
| Resources | HelpResources.tsx | /help/resources |
|
||||
| Staff | HelpStaff.tsx | /help/staff |
|
||||
|
||||
### 3.3 Communicate Section Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| Messages | HelpMessages.tsx | /help/messages |
|
||||
| Tickets | HelpTicketing.tsx (exists) | /help/ticketing |
|
||||
|
||||
### 3.4 Money Section Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| Payments | HelpPayments.tsx | /help/payments |
|
||||
|
||||
### 3.5 Extend Section Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| Plugins | HelpPluginsOverview.tsx | /help/plugins-overview |
|
||||
| Plugin Marketplace | (link to existing HelpPluginDocs) | /help/plugins |
|
||||
| My Plugins | HelpMyPlugins.tsx | /help/my-plugins |
|
||||
| Create Plugin | HelpCreatePlugin.tsx | /help/create-plugin |
|
||||
|
||||
### 3.6 Settings Section Help
|
||||
| Page | Help File | Route |
|
||||
|------|-----------|-------|
|
||||
| General | HelpSettingsGeneral.tsx | /help/settings/general |
|
||||
| Resource Types | HelpSettingsResourceTypes.tsx | /help/settings/resource-types |
|
||||
| Booking | HelpSettingsBooking.tsx | /help/settings/booking |
|
||||
| Appearance | HelpSettingsAppearance.tsx | /help/settings/appearance |
|
||||
| Email Templates | HelpSettingsEmailTemplates.tsx | /help/settings/email-templates |
|
||||
| Custom Domains | HelpSettingsCustomDomains.tsx | /help/settings/custom-domains |
|
||||
| API & Webhooks | HelpSettingsApi.tsx | /help/settings/api |
|
||||
| Authentication | HelpSettingsAuth.tsx | /help/settings/authentication |
|
||||
| Email Setup | HelpEmailSettings.tsx (exists) | /help/email-settings |
|
||||
| SMS & Calling | HelpSettingsSmsCalling.tsx | /help/settings/sms-calling |
|
||||
| Plan & Billing | HelpSettingsBilling.tsx | /help/settings/billing |
|
||||
| Quota Management | HelpSettingsQuota.tsx | /help/settings/quota |
|
||||
|
||||
## Phase 4: Add HelpButton to Each Page
|
||||
|
||||
Add the HelpButton component to the top-right of each dashboard page, linking to its corresponding help page.
|
||||
|
||||
## Phase 5: Update HelpPluginDocs
|
||||
|
||||
### Task 5.1: Review and Update Plugin Documentation
|
||||
- Verify plugin documentation matches current codebase
|
||||
- Add section for "Creating Custom Plugins"
|
||||
- Add links to API documentation
|
||||
- Ensure examples work with current API
|
||||
|
||||
## Phase 6: Create Monolithic Help Document
|
||||
|
||||
### Task 6.1: Create HelpGuideComplete.tsx
|
||||
- Compile all help content into single comprehensive page
|
||||
- Table of contents with anchor links
|
||||
- Searchable content
|
||||
- Organized by sections (Core, Manage, Communicate, Money, Extend, Settings)
|
||||
|
||||
### Task 6.2: Update HelpGuide.tsx
|
||||
- Replace "Coming Soon" with actual compiled documentation
|
||||
- Or redirect to HelpGuideComplete
|
||||
|
||||
## Phase 7: Register All Routes
|
||||
|
||||
Add all new help page routes to App.tsx in the business dashboard section.
|
||||
|
||||
## Help Page Template Structure
|
||||
|
||||
Each help page should follow this structure:
|
||||
```tsx
|
||||
- Header with icon and title
|
||||
- Overview/Introduction
|
||||
- Key Features section
|
||||
- How to Use section (step-by-step)
|
||||
- Benefits section
|
||||
- Tips & Best Practices
|
||||
- Related Features (links to other help pages)
|
||||
- Need More Help? (link to support/tickets)
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create CreatePlugin.tsx page and route
|
||||
2. Create HelpButton component
|
||||
3. Create help pages for core pages (Dashboard, Scheduler, Tasks)
|
||||
4. Create help pages for Manage section
|
||||
5. Create help pages for Communicate section
|
||||
6. Create help pages for Money section
|
||||
7. Create help pages for Extend section (including plugin docs update)
|
||||
8. Create help pages for Settings section
|
||||
9. Add HelpButton to all pages
|
||||
10. Create monolithic help document
|
||||
11. Test all help pages and navigation
|
||||
|
||||
## Files to Create
|
||||
|
||||
### New Components
|
||||
- `/frontend/src/components/HelpButton.tsx`
|
||||
|
||||
### New Pages
|
||||
- `/frontend/src/pages/CreatePlugin.tsx`
|
||||
- `/frontend/src/pages/help/HelpDashboard.tsx`
|
||||
- `/frontend/src/pages/help/HelpScheduler.tsx`
|
||||
- `/frontend/src/pages/help/HelpTasks.tsx`
|
||||
- `/frontend/src/pages/help/HelpCustomers.tsx`
|
||||
- `/frontend/src/pages/help/HelpServices.tsx`
|
||||
- `/frontend/src/pages/help/HelpResources.tsx`
|
||||
- `/frontend/src/pages/help/HelpStaff.tsx`
|
||||
- `/frontend/src/pages/help/HelpMessages.tsx`
|
||||
- `/frontend/src/pages/help/HelpPayments.tsx`
|
||||
- `/frontend/src/pages/help/HelpPluginsOverview.tsx`
|
||||
- `/frontend/src/pages/help/HelpMyPlugins.tsx`
|
||||
- `/frontend/src/pages/help/HelpCreatePlugin.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsGeneral.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsResourceTypes.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsBooking.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsAppearance.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsEmailTemplates.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsCustomDomains.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsApi.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsAuth.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsSmsCalling.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsBilling.tsx`
|
||||
- `/frontend/src/pages/help/HelpSettingsQuota.tsx`
|
||||
- `/frontend/src/pages/help/HelpGuideComplete.tsx`
|
||||
|
||||
### Files to Modify
|
||||
- `/frontend/src/App.tsx` - Add routes
|
||||
- `/frontend/src/pages/HelpPluginDocs.tsx` - Update with current codebase info
|
||||
- `/frontend/src/pages/HelpGuide.tsx` - Replace Coming Soon
|
||||
- All dashboard pages - Add HelpButton component
|
||||
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
|
||||
|
||||
212
deploy.sh
212
deploy.sh
@@ -1,7 +1,15 @@
|
||||
#!/bin/bash
|
||||
# SmoothSchedule Production Deployment Script
|
||||
# Usage: ./deploy.sh [server_user@server_host]
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com
|
||||
# Usage: ./deploy.sh [server_user@server_host] [services...]
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com # Build all
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com traefik # Build only traefik
|
||||
# Example: ./deploy.sh poduck@smoothschedule.com django nginx # Build django and nginx
|
||||
#
|
||||
# Available services: django, traefik, nginx, postgres, celeryworker, celerybeat, flower, awscli
|
||||
# Use --no-migrate to skip migrations (useful for config-only changes like traefik)
|
||||
#
|
||||
# This script deploys from git repository, not local files.
|
||||
# Changes must be committed and pushed before deploying.
|
||||
|
||||
set -e
|
||||
|
||||
@@ -11,13 +19,38 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
SERVER=${1:-"poduck@smoothschedule.com"}
|
||||
PROJECT_DIR="/home/poduck/Desktop/smoothschedule2"
|
||||
# Parse arguments
|
||||
SERVER=""
|
||||
SERVICES=""
|
||||
SKIP_MIGRATE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--no-migrate" ]]; then
|
||||
SKIP_MIGRATE=true
|
||||
elif [[ -z "$SERVER" ]]; then
|
||||
SERVER="$arg"
|
||||
else
|
||||
SERVICES="$SERVICES $arg"
|
||||
fi
|
||||
done
|
||||
|
||||
SERVER=${SERVER:-"poduck@smoothschedule.com"}
|
||||
SERVICES=$(echo "$SERVICES" | xargs) # Trim whitespace
|
||||
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
|
||||
REMOTE_DIR="/home/poduck/smoothschedule"
|
||||
|
||||
echo -e "${GREEN}==================================="
|
||||
echo "SmoothSchedule Deployment"
|
||||
echo "===================================${NC}"
|
||||
echo "Target server: $SERVER"
|
||||
if [[ -n "$SERVICES" ]]; then
|
||||
echo "Services to rebuild: $SERVICES"
|
||||
else
|
||||
echo "Services to rebuild: ALL"
|
||||
fi
|
||||
if [[ "$SKIP_MIGRATE" == "true" ]]; then
|
||||
echo "Migrations: SKIPPED"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Function to print status
|
||||
@@ -33,80 +66,141 @@ print_error() {
|
||||
echo -e "${RED}>>> $1${NC}"
|
||||
}
|
||||
|
||||
# Step 1: Build frontend
|
||||
print_status "Step 1: Skipping local build (building on server)..."
|
||||
# cd "$PROJECT_DIR/frontend"
|
||||
# if [ ! -d "node_modules" ]; then
|
||||
# print_warning "Installing frontend dependencies..."
|
||||
# npm install
|
||||
# fi
|
||||
# npm run build
|
||||
# print_status "Frontend build complete!"
|
||||
# Step 1: Check for uncommitted changes
|
||||
print_status "Step 1: Checking for uncommitted changes..."
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
print_error "You have uncommitted changes. Please commit and push before deploying."
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Prepare deployment package
|
||||
print_status "Step 2: Preparing deployment package..."
|
||||
cd "$PROJECT_DIR"
|
||||
mkdir -p /tmp/smoothschedule-deploy
|
||||
# Check if local is ahead of remote
|
||||
LOCAL_COMMIT=$(git rev-parse HEAD)
|
||||
REMOTE_COMMIT=$(git rev-parse @{u} 2>/dev/null || echo "")
|
||||
|
||||
# Copy backend
|
||||
rsync -av --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' \
|
||||
--exclude='.git' --exclude='node_modules' \
|
||||
"$PROJECT_DIR/smoothschedule/" /tmp/smoothschedule-deploy/backend/
|
||||
if [[ -z "$REMOTE_COMMIT" ]]; then
|
||||
print_error "No upstream branch configured. Please push your changes first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy frontend source
|
||||
rsync -av --exclude='node_modules' --exclude='dist' --exclude='.git' \
|
||||
"$PROJECT_DIR/frontend/" /tmp/smoothschedule-deploy/frontend/
|
||||
if [[ "$LOCAL_COMMIT" != "$REMOTE_COMMIT" ]]; then
|
||||
print_warning "Local branch differs from remote. Checking if ahead..."
|
||||
AHEAD=$(git rev-list --count @{u}..HEAD)
|
||||
if [[ "$AHEAD" -gt 0 ]]; then
|
||||
print_error "You have $AHEAD unpushed commit(s). Please push before deploying."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_status "Deployment package prepared!"
|
||||
print_status "All changes committed and pushed!"
|
||||
|
||||
# Step 3: Upload to server
|
||||
print_status "Step 3: Uploading to server..."
|
||||
ssh "$SERVER" "mkdir -p ~/smoothschedule"
|
||||
# Step 2: Deploy on server
|
||||
print_status "Step 2: Deploying on server..."
|
||||
|
||||
# Upload backend
|
||||
rsync -avz --delete /tmp/smoothschedule-deploy/backend/ "$SERVER:~/smoothschedule/"
|
||||
|
||||
# Upload nginx config
|
||||
scp "$PROJECT_DIR/frontend/nginx.conf" "$SERVER:~/smoothschedule/nginx.conf"
|
||||
|
||||
# Upload frontend
|
||||
rsync -avz --delete /tmp/smoothschedule-deploy/frontend/ "$SERVER:~/smoothschedule-frontend/"
|
||||
|
||||
print_status "Files uploaded!"
|
||||
|
||||
# Step 4: Deploy on server
|
||||
print_status "Step 4: Deploying on server..."
|
||||
|
||||
ssh "$SERVER" 'bash -s' << 'ENDSSH'
|
||||
ssh "$SERVER" "bash -s" << ENDSSH
|
||||
set -e
|
||||
|
||||
echo ">>> Navigating to project directory..."
|
||||
cd ~/smoothschedule
|
||||
echo ">>> Setting up project directory..."
|
||||
|
||||
echo ">>> Building Docker images..."
|
||||
docker compose -f docker-compose.production.yml build
|
||||
# Backup .envs if they exist (secrets not in git)
|
||||
if [ -d "$REMOTE_DIR/smoothschedule/.envs" ]; then
|
||||
echo ">>> Backing up .envs secrets..."
|
||||
cp -r "$REMOTE_DIR/smoothschedule/.envs" /tmp/.envs-backup
|
||||
elif [ -d "$REMOTE_DIR/.envs" ]; then
|
||||
# Old structure - .envs was at root level
|
||||
echo ">>> Backing up .envs secrets (old location)..."
|
||||
cp -r "$REMOTE_DIR/.envs" /tmp/.envs-backup
|
||||
fi
|
||||
|
||||
# Backup .ssh if it exists (SSH keys not in git)
|
||||
if [ -d "$REMOTE_DIR/smoothschedule/.ssh" ]; then
|
||||
echo ">>> Backing up .ssh keys..."
|
||||
cp -r "$REMOTE_DIR/smoothschedule/.ssh" /tmp/.ssh-backup
|
||||
elif [ -d "$REMOTE_DIR/.ssh" ]; then
|
||||
# Old structure
|
||||
echo ">>> Backing up .ssh keys (old location)..."
|
||||
cp -r "$REMOTE_DIR/.ssh" /tmp/.ssh-backup
|
||||
fi
|
||||
|
||||
if [ ! -d "$REMOTE_DIR/.git" ]; then
|
||||
echo ">>> Cloning repository for the first time..."
|
||||
# Remove old non-git deployment if exists
|
||||
if [ -d "$REMOTE_DIR" ]; then
|
||||
rm -rf "$REMOTE_DIR"
|
||||
fi
|
||||
git clone "$REPO_URL" "$REMOTE_DIR"
|
||||
else
|
||||
echo ">>> Repository exists, pulling latest changes..."
|
||||
cd "$REMOTE_DIR"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
|
||||
# Restore .envs secrets
|
||||
if [ -d /tmp/.envs-backup ] && [ "$(ls -A /tmp/.envs-backup 2>/dev/null)" ]; then
|
||||
echo ">>> Restoring .envs secrets..."
|
||||
mkdir -p "$REMOTE_DIR/smoothschedule/.envs"
|
||||
cp -r /tmp/.envs-backup/* "$REMOTE_DIR/smoothschedule/.envs/"
|
||||
rm -rf /tmp/.envs-backup
|
||||
fi
|
||||
|
||||
# Restore .ssh keys
|
||||
if [ -d /tmp/.ssh-backup ] && [ "$(ls -A /tmp/.ssh-backup 2>/dev/null)" ]; then
|
||||
echo ">>> Restoring .ssh keys..."
|
||||
mkdir -p "$REMOTE_DIR/smoothschedule/.ssh"
|
||||
cp -r /tmp/.ssh-backup/* "$REMOTE_DIR/smoothschedule/.ssh/"
|
||||
rm -rf /tmp/.ssh-backup
|
||||
fi
|
||||
|
||||
echo ">>> Current commit:"
|
||||
git log -1 --oneline
|
||||
|
||||
cd smoothschedule
|
||||
|
||||
# Build images (all or specific services)
|
||||
if [[ -n "$SERVICES" ]]; then
|
||||
echo ">>> Building Docker images: $SERVICES..."
|
||||
docker compose -f docker-compose.production.yml build $SERVICES
|
||||
else
|
||||
echo ">>> Building all Docker images..."
|
||||
docker compose -f docker-compose.production.yml build
|
||||
fi
|
||||
|
||||
echo ">>> Starting containers..."
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
echo ">>> Waiting for containers to start..."
|
||||
sleep 10
|
||||
sleep 5
|
||||
|
||||
echo ">>> Running database migrations..."
|
||||
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 manage.py migrate'
|
||||
# Run migrations unless skipped
|
||||
if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
||||
echo ">>> Running database migrations..."
|
||||
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 manage.py migrate'
|
||||
|
||||
echo ">>> Collecting static files..."
|
||||
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 manage.py collectstatic --noinput'
|
||||
echo ">>> Collecting static files..."
|
||||
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 manage.py collectstatic --noinput'
|
||||
|
||||
echo ">>> Checking container status..."
|
||||
docker compose -f docker-compose.production.yml ps
|
||||
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()
|
||||
for tenant in Tenant.objects.exclude(schema_name=\"public\"):
|
||||
print(f\" Seeding plugins for {tenant.schema_name}...\")
|
||||
call_command(\"tenant_command\", \"seed_platform_plugins\", schema=tenant.schema_name, verbosity=0)
|
||||
print(\" Done!\")
|
||||
"'
|
||||
else
|
||||
echo ">>> Skipping migrations (--no-migrate flag used)"
|
||||
fi
|
||||
|
||||
echo ">>> Deployment complete!"
|
||||
ENDSSH
|
||||
|
||||
# Cleanup
|
||||
rm -rf /tmp/smoothschedule-deploy
|
||||
|
||||
echo ""
|
||||
print_status "==================================="
|
||||
print_status "Deployment Complete!"
|
||||
@@ -118,7 +212,7 @@ echo " - https://platform.smoothschedule.com"
|
||||
echo " - https://*.smoothschedule.com (tenant subdomains)"
|
||||
echo ""
|
||||
echo "To view logs:"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml logs -f'"
|
||||
echo ""
|
||||
echo "To check status:"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule && docker compose -f docker-compose.production.yml ps'"
|
||||
echo " ssh $SERVER 'cd ~/smoothschedule/smoothschedule && docker compose -f docker-compose.production.yml ps'"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
VITE_DEV_MODE=true
|
||||
VITE_API_URL=http://api.lvh.me:8000
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
|
||||
VITE_GOOGLE_MAPS_API_KEY=
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -2,10 +2,31 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
|
||||
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
|
||||
<title>Smooth Schedule | Online Appointment Scheduling Software</title>
|
||||
<meta name="description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly. Start free today." />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
|
||||
<meta property="og:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
|
||||
<meta property="og:image" content="https://smoothschedule.com/og-image.png" />
|
||||
<meta property="og:url" content="https://smoothschedule.com" />
|
||||
<meta property="og:site_name" content="Smooth Schedule" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Smooth Schedule | Online Appointment Scheduling Software" />
|
||||
<meta name="twitter:description" content="The all-in-one scheduling platform for service businesses. Manage appointments, staff, and payments effortlessly." />
|
||||
<meta name="twitter:image" content="https://smoothschedule.com/og-image.png" />
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="author" content="Smooth Schedule Inc." />
|
||||
<link rel="canonical" href="https://smoothschedule.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Ensure full height for the app */
|
||||
|
||||
@@ -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;
|
||||
|
||||
1714
frontend/package-lock.json
generated
1714
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,14 @@
|
||||
"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",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-grid-layout": "^1.3.6",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -21,6 +24,7 @@
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
@@ -32,27 +36,38 @@
|
||||
"@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",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.15"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1,5 +1,12 @@
|
||||
# robots.txt - SmoothSchedule
|
||||
# Deny all robots while in development
|
||||
# Currently blocking all crawlers - site not yet live
|
||||
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
# When ready to go live, replace above with:
|
||||
# User-agent: *
|
||||
# Allow: /
|
||||
# Disallow: /api/
|
||||
# Disallow: /admin/
|
||||
# Sitemap: https://smoothschedule.com/sitemap.xml
|
||||
|
||||
51
frontend/public/sitemap.xml
Normal file
51
frontend/public/sitemap.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/features</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/pricing</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/about</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/contact</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/signup</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/privacy</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://smoothschedule.com/terms</loc>
|
||||
<lastmod>2024-12-04</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
||||
import { setCookie } from './utils/cookies';
|
||||
|
||||
// Import Login Page
|
||||
@@ -34,17 +35,23 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
||||
|
||||
// Import pages
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||
const Settings = React.lazy(() => import('./pages/Settings'));
|
||||
const Payments = React.lazy(() => import('./pages/Payments'));
|
||||
const Messages = React.lazy(() => import('./pages/Messages'));
|
||||
const Resources = React.lazy(() => import('./pages/Resources'));
|
||||
const Services = React.lazy(() => import('./pages/Services'));
|
||||
const Staff = React.lazy(() => import('./pages/Staff'));
|
||||
const TimeBlocks = React.lazy(() => import('./pages/TimeBlocks'));
|
||||
const MyAvailability = React.lazy(() => import('./pages/MyAvailability'));
|
||||
const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
|
||||
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
||||
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
||||
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
||||
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
|
||||
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
||||
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
||||
|
||||
@@ -61,17 +68,50 @@ const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
||||
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
|
||||
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
|
||||
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
|
||||
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
|
||||
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
||||
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
|
||||
const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page
|
||||
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
|
||||
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
|
||||
|
||||
// Import new help pages
|
||||
const HelpDashboard = React.lazy(() => import('./pages/help/HelpDashboard'));
|
||||
const HelpScheduler = React.lazy(() => import('./pages/help/HelpScheduler'));
|
||||
const HelpTasks = React.lazy(() => import('./pages/help/HelpTasks'));
|
||||
const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
|
||||
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
|
||||
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
|
||||
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
|
||||
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
|
||||
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
||||
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
|
||||
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
|
||||
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
|
||||
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
||||
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
||||
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
|
||||
const HelpSettingsAppearance = React.lazy(() => import('./pages/help/HelpSettingsAppearance'));
|
||||
const HelpSettingsEmail = React.lazy(() => import('./pages/help/HelpSettingsEmail'));
|
||||
const HelpSettingsDomains = React.lazy(() => import('./pages/help/HelpSettingsDomains'));
|
||||
const HelpSettingsApi = React.lazy(() => import('./pages/help/HelpSettingsApi'));
|
||||
const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'));
|
||||
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
||||
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
||||
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
||||
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
||||
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
||||
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
|
||||
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
|
||||
|
||||
// Settings pages
|
||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||
@@ -159,6 +199,7 @@ const AppContent: React.FC = () => {
|
||||
const updateBusinessMutation = useUpdateBusiness();
|
||||
const masqueradeMutation = useMasquerade();
|
||||
const logoutMutation = useLogout();
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
// Apply dark mode class and persist to localStorage
|
||||
React.useEffect(() => {
|
||||
@@ -166,6 +207,30 @@ const AppContent: React.FC = () => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
}, [darkMode]);
|
||||
|
||||
// Set noindex/nofollow for app subdomains (platform, business subdomains)
|
||||
// Only the root domain marketing pages should be indexed
|
||||
React.useEffect(() => {
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
const hasSubdomain = parts.length > 2 || (parts.length === 2 && parts[0] !== 'localhost');
|
||||
|
||||
// Check if we're on a subdomain (platform.*, demo.*, etc.)
|
||||
const isSubdomain = hostname !== 'localhost' && hostname !== '127.0.0.1' && parts.length > 2;
|
||||
|
||||
if (isSubdomain) {
|
||||
// Always noindex/nofollow on subdomains (app areas)
|
||||
let metaRobots = document.querySelector('meta[name="robots"]');
|
||||
if (metaRobots) {
|
||||
metaRobots.setAttribute('content', 'noindex, nofollow');
|
||||
} else {
|
||||
metaRobots = document.createElement('meta');
|
||||
metaRobots.setAttribute('name', 'robots');
|
||||
metaRobots.setAttribute('content', 'noindex, nofollow');
|
||||
document.head.appendChild(metaRobots);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle tokens in URL (from login or masquerade redirect)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -255,31 +320,50 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - redirect to root domain for login if on subdomain
|
||||
// Not authenticated - show appropriate page based on subdomain
|
||||
if (!user) {
|
||||
// If on a subdomain, redirect to root domain login page
|
||||
const currentHostname = window.location.hostname;
|
||||
const hostnameParts = currentHostname.split('.');
|
||||
const baseDomain = hostnameParts.length >= 2
|
||||
? hostnameParts.slice(-2).join('.')
|
||||
: currentHostname;
|
||||
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
|
||||
const isPlatformSubdomain = hostnameParts[0] === 'platform';
|
||||
const currentSubdomain = hostnameParts[0];
|
||||
|
||||
if (!isRootDomainForUnauthUser) {
|
||||
// Redirect to root domain login (preserve port)
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
||||
return <LoadingScreen />;
|
||||
// Check if we're on a business subdomain (not root, not platform, not api)
|
||||
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
|
||||
|
||||
// For business subdomains, show the tenant landing page with login option
|
||||
if (isBusinessSubdomain) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<PublicPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// For root domain or platform subdomain, show marketing site / login
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<Routes>
|
||||
@@ -298,7 +382,9 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -470,7 +556,7 @@ const AppContent: React.FC = () => {
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/payments" element={<CustomerBilling />} />
|
||||
<Route path="/support" element={<CustomerSupport />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
@@ -585,15 +671,59 @@ const AppContent: React.FC = () => {
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||
/>
|
||||
{/* Staff Schedule - vertical timeline view */}
|
||||
<Route
|
||||
path="/my-schedule"
|
||||
element={
|
||||
hasAccess(['staff']) ? (
|
||||
<StaffSchedule user={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/tickets" element={<Tickets />} />
|
||||
<Route
|
||||
path="/help"
|
||||
element={
|
||||
user.role === 'staff' ? (
|
||||
<StaffHelp user={user} />
|
||||
) : (
|
||||
<HelpComprehensive />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
|
||||
<Route path="/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="/plugins/marketplace"
|
||||
element={
|
||||
@@ -614,6 +744,16 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/create"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<CreatePlugin />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks"
|
||||
element={
|
||||
@@ -638,7 +778,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
@@ -648,7 +788,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
@@ -658,7 +798,7 @@ const AppContent: React.FC = () => {
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
@@ -675,6 +815,46 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/time-blocks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<TimeBlocks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-availability"
|
||||
element={
|
||||
hasAccess(['staff', 'resource']) ? (
|
||||
<MyAvailability user={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
<Contracts />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contracts/templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||
<ContractTemplates />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={
|
||||
@@ -683,12 +863,19 @@ const AppContent: React.FC = () => {
|
||||
/>
|
||||
<Route
|
||||
path="/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
||||
<Messages />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/site-editor"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Messages</h1>
|
||||
<p className="text-gray-600">Messages feature coming soon...</p>
|
||||
</div>
|
||||
<PageEditor />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
|
||||
159
frontend/src/api/__tests__/auth.test.ts
Normal file
159
frontend/src/api/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
login,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
refreshToken,
|
||||
masquerade,
|
||||
stopMasquerade,
|
||||
} from '../auth';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('auth API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('sends credentials to login endpoint', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: { id: 1, email: 'test@example.com' },
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await login({ email: 'test@example.com', password: 'password' });
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/login/', {
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('returns MFA required response', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
mfa_required: true,
|
||||
user_id: 1,
|
||||
mfa_methods: ['TOTP', 'SMS'],
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await login({ email: 'test@example.com', password: 'password' });
|
||||
|
||||
expect(result.mfa_required).toBe(true);
|
||||
expect(result.mfa_methods).toContain('TOTP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('calls logout endpoint', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await logout();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('fetches current user from API', async () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'owner',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser });
|
||||
|
||||
const result = await getCurrentUser();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/me/');
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('sends refresh token to API', async () => {
|
||||
const mockResponse = { data: { access: 'new-access-token' } };
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await refreshToken('old-refresh-token');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh/', {
|
||||
refresh: 'old-refresh-token',
|
||||
});
|
||||
expect(result.access).toBe('new-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('masquerade', () => {
|
||||
it('sends masquerade request with user_pk', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access: 'masq-access',
|
||||
refresh: 'masq-refresh',
|
||||
user: { id: 2, email: 'other@example.com' },
|
||||
masquerade_stack: [{ user_id: 1, username: 'admin', role: 'superuser' }],
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await masquerade(2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
|
||||
user_pk: 2,
|
||||
hijack_history: undefined,
|
||||
});
|
||||
expect(result.masquerade_stack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends masquerade request with history', async () => {
|
||||
const history = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await masquerade(2, history);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
|
||||
user_pk: 2,
|
||||
hijack_history: history,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopMasquerade', () => {
|
||||
it('sends release request with masquerade stack', async () => {
|
||||
const stack = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
|
||||
const mockResponse = {
|
||||
data: {
|
||||
access: 'orig-access',
|
||||
refresh: 'orig-refresh',
|
||||
user: { id: 1 },
|
||||
masquerade_stack: [],
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await stopMasquerade(stack);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/release/', {
|
||||
masquerade_stack: stack,
|
||||
});
|
||||
expect(result.masquerade_stack).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
frontend/src/api/__tests__/billing.test.ts
Normal file
212
frontend/src/api/__tests__/billing.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
632
frontend/src/api/__tests__/business.test.ts
Normal file
632
frontend/src/api/__tests__/business.test.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getResources,
|
||||
getBusinessUsers,
|
||||
getBusinessOAuthSettings,
|
||||
updateBusinessOAuthSettings,
|
||||
getBusinessOAuthCredentials,
|
||||
updateBusinessOAuthCredentials,
|
||||
} from '../business';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('business API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getResources', () => {
|
||||
it('fetches all resources from API', async () => {
|
||||
const mockResources = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Resource 1',
|
||||
type: 'STAFF',
|
||||
maxConcurrentEvents: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Resource 2',
|
||||
type: 'EQUIPMENT',
|
||||
maxConcurrentEvents: 3,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
|
||||
|
||||
const result = await getResources();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
|
||||
expect(result).toEqual(mockResources);
|
||||
});
|
||||
|
||||
it('returns empty array when no resources exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getResources();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBusinessUsers', () => {
|
||||
it('fetches all business users from API', async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Business Owner',
|
||||
role: 'owner',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'staff@example.com',
|
||||
name: 'Staff Member',
|
||||
role: 'staff',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
|
||||
|
||||
const result = await getBusinessUsers();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/business/users/');
|
||||
expect(result).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('returns empty array when no users exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getBusinessUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBusinessOAuthSettings', () => {
|
||||
it('fetches OAuth settings and transforms snake_case to camelCase', async () => {
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: ['google', 'microsoft'],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: true,
|
||||
},
|
||||
available_providers: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'google-icon',
|
||||
description: 'Sign in with Google',
|
||||
},
|
||||
{
|
||||
id: 'microsoft',
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft-icon',
|
||||
description: 'Sign in with Microsoft',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthSettings();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-settings/');
|
||||
expect(result).toEqual({
|
||||
settings: {
|
||||
enabledProviders: ['google', 'microsoft'],
|
||||
allowRegistration: true,
|
||||
autoLinkByEmail: false,
|
||||
useCustomCredentials: true,
|
||||
},
|
||||
availableProviders: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'google-icon',
|
||||
description: 'Sign in with Google',
|
||||
},
|
||||
{
|
||||
id: 'microsoft',
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft-icon',
|
||||
description: 'Sign in with Microsoft',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty enabled providers array', async () => {
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: false,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthSettings();
|
||||
|
||||
expect(result.settings.enabledProviders).toEqual([]);
|
||||
expect(result.availableProviders).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles undefined enabled_providers by using empty array', async () => {
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
allow_registration: true,
|
||||
auto_link_by_email: true,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'google-icon',
|
||||
description: 'Google OAuth',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthSettings();
|
||||
|
||||
expect(result.settings.enabledProviders).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles undefined available_providers by using empty array', async () => {
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: ['google'],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: true,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthSettings();
|
||||
|
||||
expect(result.availableProviders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBusinessOAuthSettings', () => {
|
||||
it('updates OAuth settings and transforms camelCase to snake_case', async () => {
|
||||
const frontendSettings = {
|
||||
enabledProviders: ['google', 'microsoft'],
|
||||
allowRegistration: true,
|
||||
autoLinkByEmail: false,
|
||||
useCustomCredentials: true,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: ['google', 'microsoft'],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: true,
|
||||
},
|
||||
available_providers: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'google-icon',
|
||||
description: 'Google OAuth',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await updateBusinessOAuthSettings(frontendSettings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
enabled_providers: ['google', 'microsoft'],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
settings: {
|
||||
enabledProviders: ['google', 'microsoft'],
|
||||
allowRegistration: true,
|
||||
autoLinkByEmail: false,
|
||||
useCustomCredentials: true,
|
||||
},
|
||||
availableProviders: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'google-icon',
|
||||
description: 'Google OAuth',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('sends only provided fields to backend', async () => {
|
||||
const partialSettings = {
|
||||
enabledProviders: ['google'],
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: ['google'],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(partialSettings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
enabled_providers: ['google'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles updating only allowRegistration', async () => {
|
||||
const partialSettings = {
|
||||
allowRegistration: false,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: false,
|
||||
auto_link_by_email: true,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(partialSettings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
allow_registration: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles updating only autoLinkByEmail', async () => {
|
||||
const partialSettings = {
|
||||
autoLinkByEmail: true,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: false,
|
||||
auto_link_by_email: true,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(partialSettings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
auto_link_by_email: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles updating only useCustomCredentials', async () => {
|
||||
const partialSettings = {
|
||||
useCustomCredentials: true,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: false,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: true,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(partialSettings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
use_custom_credentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles boolean false values correctly', async () => {
|
||||
const settings = {
|
||||
allowRegistration: false,
|
||||
autoLinkByEmail: false,
|
||||
useCustomCredentials: false,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: false,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(settings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
|
||||
allow_registration: false,
|
||||
auto_link_by_email: false,
|
||||
use_custom_credentials: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send undefined fields', async () => {
|
||||
const settings = {};
|
||||
|
||||
const mockBackendResponse = {
|
||||
settings: {
|
||||
enabled_providers: [],
|
||||
allow_registration: true,
|
||||
auto_link_by_email: true,
|
||||
use_custom_credentials: false,
|
||||
},
|
||||
available_providers: [],
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthSettings(settings);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBusinessOAuthCredentials', () => {
|
||||
it('fetches OAuth credentials from API', async () => {
|
||||
const mockBackendResponse = {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'google-client-id',
|
||||
client_secret: 'google-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
microsoft: {
|
||||
client_id: 'microsoft-client-id',
|
||||
client_secret: '',
|
||||
has_secret: false,
|
||||
},
|
||||
},
|
||||
use_custom_credentials: true,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthCredentials();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-credentials/');
|
||||
expect(result).toEqual({
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'google-client-id',
|
||||
client_secret: 'google-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
microsoft: {
|
||||
client_id: 'microsoft-client-id',
|
||||
client_secret: '',
|
||||
has_secret: false,
|
||||
},
|
||||
},
|
||||
useCustomCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty credentials object', async () => {
|
||||
const mockBackendResponse = {
|
||||
credentials: {},
|
||||
use_custom_credentials: false,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthCredentials();
|
||||
|
||||
expect(result.credentials).toEqual({});
|
||||
expect(result.useCustomCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it('handles undefined credentials by using empty object', async () => {
|
||||
const mockBackendResponse = {
|
||||
use_custom_credentials: false,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await getBusinessOAuthCredentials();
|
||||
|
||||
expect(result.credentials).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBusinessOAuthCredentials', () => {
|
||||
it('updates OAuth credentials', async () => {
|
||||
const credentials = {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'new-google-client-id',
|
||||
client_secret: 'new-google-secret',
|
||||
},
|
||||
},
|
||||
useCustomCredentials: true,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'new-google-client-id',
|
||||
client_secret: 'new-google-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
},
|
||||
use_custom_credentials: true,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await updateBusinessOAuthCredentials(credentials);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'new-google-client-id',
|
||||
client_secret: 'new-google-secret',
|
||||
},
|
||||
},
|
||||
use_custom_credentials: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'new-google-client-id',
|
||||
client_secret: 'new-google-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
},
|
||||
useCustomCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only credentials without useCustomCredentials', async () => {
|
||||
const data = {
|
||||
credentials: {
|
||||
microsoft: {
|
||||
client_id: 'microsoft-id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
credentials: {
|
||||
microsoft: {
|
||||
client_id: 'microsoft-id',
|
||||
client_secret: '',
|
||||
has_secret: false,
|
||||
},
|
||||
},
|
||||
use_custom_credentials: false,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthCredentials(data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
|
||||
credentials: {
|
||||
microsoft: {
|
||||
client_id: 'microsoft-id',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only useCustomCredentials without credentials', async () => {
|
||||
const data = {
|
||||
useCustomCredentials: false,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
credentials: {},
|
||||
use_custom_credentials: false,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthCredentials(data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
|
||||
use_custom_credentials: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles partial credential updates', async () => {
|
||||
const data = {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'updated-id',
|
||||
},
|
||||
microsoft: {
|
||||
client_secret: 'updated-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'updated-id',
|
||||
client_secret: 'existing-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
microsoft: {
|
||||
client_id: 'existing-id',
|
||||
client_secret: 'updated-secret',
|
||||
has_secret: true,
|
||||
},
|
||||
},
|
||||
use_custom_credentials: true,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await updateBusinessOAuthCredentials(data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
|
||||
credentials: {
|
||||
google: {
|
||||
client_id: 'updated-id',
|
||||
},
|
||||
microsoft: {
|
||||
client_secret: 'updated-secret',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.credentials.google.client_id).toBe('updated-id');
|
||||
expect(result.credentials.microsoft.client_secret).toBe('updated-secret');
|
||||
});
|
||||
|
||||
it('handles empty data object', async () => {
|
||||
const data = {};
|
||||
|
||||
const mockBackendResponse = {
|
||||
credentials: {},
|
||||
use_custom_credentials: false,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
await updateBusinessOAuthCredentials(data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {});
|
||||
});
|
||||
|
||||
it('handles undefined credentials in response by using empty object', async () => {
|
||||
const data = {
|
||||
useCustomCredentials: true,
|
||||
};
|
||||
|
||||
const mockBackendResponse = {
|
||||
use_custom_credentials: true,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
|
||||
|
||||
const result = await updateBusinessOAuthCredentials(data);
|
||||
|
||||
expect(result.credentials).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
183
frontend/src/api/__tests__/client.test.ts
Normal file
183
frontend/src/api/__tests__/client.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../utils/cookies', () => ({
|
||||
getCookie: vi.fn(),
|
||||
setCookie: vi.fn(),
|
||||
deleteCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/domain', () => ({
|
||||
getBaseDomain: vi.fn(() => 'lvh.me'),
|
||||
}));
|
||||
|
||||
vi.mock('../config', () => ({
|
||||
API_BASE_URL: 'http://api.lvh.me:8000',
|
||||
getSubdomain: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('api/client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('request interceptor', () => {
|
||||
it('adds auth token from cookie when available', async () => {
|
||||
const cookies = await import('../../utils/cookies');
|
||||
const config = await import('../config');
|
||||
|
||||
vi.mocked(cookies.getCookie).mockReturnValue('test-token-123');
|
||||
vi.mocked(config.getSubdomain).mockReturnValue(null);
|
||||
|
||||
// Re-import client to apply mocks
|
||||
vi.resetModules();
|
||||
|
||||
// Mock the interceptors
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
// Simulate what the request interceptor does
|
||||
const token = cookies.getCookie('access_token');
|
||||
if (token) {
|
||||
mockConfig.headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['Authorization']).toBe('Token test-token-123');
|
||||
});
|
||||
|
||||
it('does not add auth header when no token', async () => {
|
||||
const cookies = await import('../../utils/cookies');
|
||||
vi.mocked(cookies.getCookie).mockReturnValue(null);
|
||||
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
const token = cookies.getCookie('access_token');
|
||||
if (token) {
|
||||
mockConfig.headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds business subdomain header when on business site', async () => {
|
||||
const config = await import('../config');
|
||||
vi.mocked(config.getSubdomain).mockReturnValue('demo');
|
||||
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
const subdomain = config.getSubdomain();
|
||||
if (subdomain && subdomain !== 'platform') {
|
||||
mockConfig.headers['X-Business-Subdomain'] = subdomain;
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['X-Business-Subdomain']).toBe('demo');
|
||||
});
|
||||
|
||||
it('does not add subdomain header on platform site', async () => {
|
||||
const config = await import('../config');
|
||||
vi.mocked(config.getSubdomain).mockReturnValue('platform');
|
||||
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
const subdomain = config.getSubdomain();
|
||||
if (subdomain && subdomain !== 'platform') {
|
||||
mockConfig.headers['X-Business-Subdomain'] = subdomain;
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['X-Business-Subdomain']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds sandbox mode header when in test mode', async () => {
|
||||
// Set sandbox mode in localStorage
|
||||
window.localStorage.setItem('sandbox_mode', 'true');
|
||||
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
// Simulate the getSandboxMode logic
|
||||
let isSandbox = false;
|
||||
try {
|
||||
isSandbox = window.localStorage.getItem('sandbox_mode') === 'true';
|
||||
} catch {
|
||||
isSandbox = false;
|
||||
}
|
||||
|
||||
if (isSandbox) {
|
||||
mockConfig.headers['X-Sandbox-Mode'] = 'true';
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['X-Sandbox-Mode']).toBe('true');
|
||||
});
|
||||
|
||||
it('does not add sandbox header when not in test mode', async () => {
|
||||
localStorage.removeItem('sandbox_mode');
|
||||
|
||||
const mockConfig = {
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
const isSandbox = localStorage.getItem('sandbox_mode') === 'true';
|
||||
if (isSandbox) {
|
||||
mockConfig.headers['X-Sandbox-Mode'] = 'true';
|
||||
}
|
||||
|
||||
expect(mockConfig.headers['X-Sandbox-Mode']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxMode', () => {
|
||||
it('returns false when localStorage throws', () => {
|
||||
// Simulate localStorage throwing (e.g., in private browsing)
|
||||
const originalGetItem = localStorage.getItem;
|
||||
localStorage.getItem = () => {
|
||||
throw new Error('Access denied');
|
||||
};
|
||||
|
||||
// Function should return false on error
|
||||
let result = false;
|
||||
try {
|
||||
result = localStorage.getItem('sandbox_mode') === 'true';
|
||||
} catch {
|
||||
result = false;
|
||||
}
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
localStorage.getItem = originalGetItem;
|
||||
});
|
||||
|
||||
it('returns false when sandbox_mode is not set', () => {
|
||||
localStorage.removeItem('sandbox_mode');
|
||||
|
||||
const result = localStorage.getItem('sandbox_mode') === 'true';
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when sandbox_mode is "true"', () => {
|
||||
window.localStorage.setItem('sandbox_mode', 'true');
|
||||
|
||||
const result = window.localStorage.getItem('sandbox_mode') === 'true';
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when sandbox_mode is "false"', () => {
|
||||
localStorage.setItem('sandbox_mode', 'false');
|
||||
|
||||
const result = localStorage.getItem('sandbox_mode') === 'true';
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
143
frontend/src/api/__tests__/config.test.ts
Normal file
143
frontend/src/api/__tests__/config.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock the domain module before importing config
|
||||
vi.mock('../../utils/domain', () => ({
|
||||
getBaseDomain: vi.fn(),
|
||||
isRootDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to mock window.location
|
||||
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
hostname,
|
||||
protocol,
|
||||
port,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
describe('api/config', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
// Clear any env vars
|
||||
delete (import.meta as unknown as { env: Record<string, unknown> }).env.VITE_API_URL;
|
||||
});
|
||||
|
||||
describe('getSubdomain', () => {
|
||||
it('returns null for root domain', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(true);
|
||||
mockLocation('lvh.me');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns subdomain for business site', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('demo.lvh.me');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBe('demo');
|
||||
});
|
||||
|
||||
it('returns null for platform subdomain', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('platform.lvh.me');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns subdomain for www', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('www.lvh.me');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBe('www');
|
||||
});
|
||||
|
||||
it('returns subdomain for api', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('api.lvh.me');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBe('api');
|
||||
});
|
||||
|
||||
it('handles production business subdomain', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('acme-corp.smoothschedule.com');
|
||||
|
||||
const { getSubdomain } = await import('../config');
|
||||
expect(getSubdomain()).toBe('acme-corp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlatformSite', () => {
|
||||
it('returns true for platform subdomain', async () => {
|
||||
mockLocation('platform.lvh.me');
|
||||
|
||||
const { isPlatformSite } = await import('../config');
|
||||
expect(isPlatformSite()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for platform in production', async () => {
|
||||
mockLocation('platform.smoothschedule.com');
|
||||
|
||||
const { isPlatformSite } = await import('../config');
|
||||
expect(isPlatformSite()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for business subdomain', async () => {
|
||||
mockLocation('demo.lvh.me');
|
||||
|
||||
const { isPlatformSite } = await import('../config');
|
||||
expect(isPlatformSite()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for root domain', async () => {
|
||||
mockLocation('lvh.me');
|
||||
|
||||
const { isPlatformSite } = await import('../config');
|
||||
expect(isPlatformSite()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBusinessSite', () => {
|
||||
it('returns true for business subdomain', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('demo.lvh.me');
|
||||
|
||||
const { isBusinessSite } = await import('../config');
|
||||
expect(isBusinessSite()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for platform site', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(false);
|
||||
mockLocation('platform.lvh.me');
|
||||
|
||||
const { isBusinessSite } = await import('../config');
|
||||
expect(isBusinessSite()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for root domain', async () => {
|
||||
const domain = await import('../../utils/domain');
|
||||
vi.mocked(domain.isRootDomain).mockReturnValue(true);
|
||||
mockLocation('lvh.me');
|
||||
|
||||
const { isBusinessSite } = await import('../config');
|
||||
expect(isBusinessSite()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
frontend/src/api/__tests__/customDomains.test.ts
Normal file
267
frontend/src/api/__tests__/customDomains.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getCustomDomains,
|
||||
addCustomDomain,
|
||||
deleteCustomDomain,
|
||||
verifyCustomDomain,
|
||||
setPrimaryDomain,
|
||||
} from '../customDomains';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('customDomains API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCustomDomains', () => {
|
||||
it('fetches all custom domains for the current business', async () => {
|
||||
const mockDomains = [
|
||||
{
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
is_verified: true,
|
||||
ssl_provisioned: true,
|
||||
is_primary: true,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.example.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
verified_at: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
domain: 'custom.com',
|
||||
is_verified: false,
|
||||
ssl_provisioned: false,
|
||||
is_primary: false,
|
||||
verification_token: 'token456',
|
||||
dns_txt_record: 'smoothschedule-verify=token456',
|
||||
dns_txt_record_name: '_smoothschedule.custom.com',
|
||||
created_at: '2024-01-03T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
|
||||
|
||||
const result = await getCustomDomains();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
|
||||
expect(result).toEqual(mockDomains);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no domains exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getCustomDomains();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCustomDomain', () => {
|
||||
it('adds a new custom domain with lowercase and trimmed domain', async () => {
|
||||
const mockDomain = {
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
is_verified: false,
|
||||
ssl_provisioned: false,
|
||||
is_primary: false,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.example.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
const result = await addCustomDomain('Example.com');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
|
||||
domain: 'example.com',
|
||||
});
|
||||
expect(result).toEqual(mockDomain);
|
||||
});
|
||||
|
||||
it('transforms domain to lowercase before sending', async () => {
|
||||
const mockDomain = {
|
||||
id: 1,
|
||||
domain: 'uppercase.com',
|
||||
is_verified: false,
|
||||
ssl_provisioned: false,
|
||||
is_primary: false,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.uppercase.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
await addCustomDomain('UPPERCASE.COM');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
|
||||
domain: 'uppercase.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from domain before sending', async () => {
|
||||
const mockDomain = {
|
||||
id: 1,
|
||||
domain: 'trimmed.com',
|
||||
is_verified: false,
|
||||
ssl_provisioned: false,
|
||||
is_primary: false,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.trimmed.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
await addCustomDomain(' trimmed.com ');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
|
||||
domain: 'trimmed.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms domain with both uppercase and whitespace', async () => {
|
||||
const mockDomain = {
|
||||
id: 1,
|
||||
domain: 'mixed.com',
|
||||
is_verified: false,
|
||||
ssl_provisioned: false,
|
||||
is_primary: false,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.mixed.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
await addCustomDomain(' MiXeD.COM ');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
|
||||
domain: 'mixed.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCustomDomain', () => {
|
||||
it('deletes a custom domain by ID', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await deleteCustomDomain(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/business/domains/1/');
|
||||
});
|
||||
|
||||
it('returns void on successful deletion', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
const result = await deleteCustomDomain(42);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyCustomDomain', () => {
|
||||
it('verifies a custom domain and returns verification status', async () => {
|
||||
const mockResponse = {
|
||||
verified: true,
|
||||
message: 'Domain verified successfully',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await verifyCustomDomain(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/verify/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.message).toBe('Domain verified successfully');
|
||||
});
|
||||
|
||||
it('returns failure status when verification fails', async () => {
|
||||
const mockResponse = {
|
||||
verified: false,
|
||||
message: 'DNS records not found. Please check your configuration.',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await verifyCustomDomain(2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/2/verify/');
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.message).toContain('DNS records not found');
|
||||
});
|
||||
|
||||
it('handles different domain IDs correctly', async () => {
|
||||
const mockResponse = {
|
||||
verified: true,
|
||||
message: 'Success',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await verifyCustomDomain(999);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/999/verify/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPrimaryDomain', () => {
|
||||
it('sets a custom domain as primary', async () => {
|
||||
const mockDomain = {
|
||||
id: 1,
|
||||
domain: 'example.com',
|
||||
is_verified: true,
|
||||
ssl_provisioned: true,
|
||||
is_primary: true,
|
||||
verification_token: 'token123',
|
||||
dns_txt_record: 'smoothschedule-verify=token123',
|
||||
dns_txt_record_name: '_smoothschedule.example.com',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
verified_at: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
const result = await setPrimaryDomain(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/set-primary/');
|
||||
expect(result).toEqual(mockDomain);
|
||||
expect(result.is_primary).toBe(true);
|
||||
});
|
||||
|
||||
it('returns updated domain with is_primary flag', async () => {
|
||||
const mockDomain = {
|
||||
id: 5,
|
||||
domain: 'newprimary.com',
|
||||
is_verified: true,
|
||||
ssl_provisioned: true,
|
||||
is_primary: true,
|
||||
verification_token: 'token789',
|
||||
dns_txt_record: 'smoothschedule-verify=token789',
|
||||
dns_txt_record_name: '_smoothschedule.newprimary.com',
|
||||
created_at: '2024-01-05T00:00:00Z',
|
||||
verified_at: '2024-01-06T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
const result = await setPrimaryDomain(5);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/5/set-primary/');
|
||||
expect(result.id).toBe(5);
|
||||
expect(result.domain).toBe('newprimary.com');
|
||||
expect(result.is_primary).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
649
frontend/src/api/__tests__/domains.test.ts
Normal file
649
frontend/src/api/__tests__/domains.test.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
searchDomains,
|
||||
getDomainPrices,
|
||||
registerDomain,
|
||||
getRegisteredDomains,
|
||||
getDomainRegistration,
|
||||
updateNameservers,
|
||||
toggleAutoRenew,
|
||||
renewDomain,
|
||||
syncDomain,
|
||||
getSearchHistory,
|
||||
DomainAvailability,
|
||||
DomainPrice,
|
||||
DomainRegisterRequest,
|
||||
DomainRegistration,
|
||||
DomainSearchHistory,
|
||||
} from '../domains';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('domains API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('searchDomains', () => {
|
||||
it('searches for domains with default TLDs', async () => {
|
||||
const mockResults: DomainAvailability[] = [
|
||||
{
|
||||
domain: 'example.com',
|
||||
available: true,
|
||||
price: 12.99,
|
||||
premium: false,
|
||||
premium_price: null,
|
||||
},
|
||||
{
|
||||
domain: 'example.net',
|
||||
available: false,
|
||||
price: null,
|
||||
premium: false,
|
||||
premium_price: null,
|
||||
},
|
||||
{
|
||||
domain: 'example.org',
|
||||
available: true,
|
||||
price: 14.99,
|
||||
premium: false,
|
||||
premium_price: null,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
|
||||
|
||||
const result = await searchDomains('example');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
|
||||
query: 'example',
|
||||
tlds: ['.com', '.net', '.org'],
|
||||
});
|
||||
expect(result).toEqual(mockResults);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('searches for domains with custom TLDs', async () => {
|
||||
const mockResults: DomainAvailability[] = [
|
||||
{
|
||||
domain: 'mybusiness.io',
|
||||
available: true,
|
||||
price: 39.99,
|
||||
premium: false,
|
||||
premium_price: null,
|
||||
},
|
||||
{
|
||||
domain: 'mybusiness.dev',
|
||||
available: true,
|
||||
price: 12.99,
|
||||
premium: false,
|
||||
premium_price: null,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
|
||||
|
||||
const result = await searchDomains('mybusiness', ['.io', '.dev']);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
|
||||
query: 'mybusiness',
|
||||
tlds: ['.io', '.dev'],
|
||||
});
|
||||
expect(result).toEqual(mockResults);
|
||||
});
|
||||
|
||||
it('handles premium domain results', async () => {
|
||||
const mockResults: DomainAvailability[] = [
|
||||
{
|
||||
domain: 'premium.com',
|
||||
available: true,
|
||||
price: 12.99,
|
||||
premium: true,
|
||||
premium_price: 5000.0,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
|
||||
|
||||
const result = await searchDomains('premium');
|
||||
|
||||
expect(result[0].premium).toBe(true);
|
||||
expect(result[0].premium_price).toBe(5000.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDomainPrices', () => {
|
||||
it('fetches domain prices for all TLDs', async () => {
|
||||
const mockPrices: DomainPrice[] = [
|
||||
{
|
||||
tld: '.com',
|
||||
registration: 12.99,
|
||||
renewal: 14.99,
|
||||
transfer: 12.99,
|
||||
},
|
||||
{
|
||||
tld: '.net',
|
||||
registration: 14.99,
|
||||
renewal: 16.99,
|
||||
transfer: 14.99,
|
||||
},
|
||||
{
|
||||
tld: '.org',
|
||||
registration: 14.99,
|
||||
renewal: 16.99,
|
||||
transfer: 14.99,
|
||||
},
|
||||
{
|
||||
tld: '.io',
|
||||
registration: 39.99,
|
||||
renewal: 39.99,
|
||||
transfer: 39.99,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPrices });
|
||||
|
||||
const result = await getDomainPrices();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/domains/search/prices/');
|
||||
expect(result).toEqual(mockPrices);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerDomain', () => {
|
||||
it('registers a new domain with full contact information', async () => {
|
||||
const registerRequest: DomainRegisterRequest = {
|
||||
domain: 'newbusiness.com',
|
||||
years: 2,
|
||||
whois_privacy: true,
|
||||
auto_renew: true,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
contact: {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '+1.5551234567',
|
||||
address: '123 Main St',
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
zip_code: '10001',
|
||||
country: 'US',
|
||||
},
|
||||
auto_configure: true,
|
||||
};
|
||||
|
||||
const mockRegistration: DomainRegistration = {
|
||||
id: 1,
|
||||
domain: 'newbusiness.com',
|
||||
status: 'pending',
|
||||
registered_at: null,
|
||||
expires_at: null,
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 25.98,
|
||||
renewal_price: null,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
days_until_expiry: null,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
registrant_first_name: 'John',
|
||||
registrant_last_name: 'Doe',
|
||||
registrant_email: 'john@example.com',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
|
||||
|
||||
const result = await registerDomain(registerRequest);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/register/', registerRequest);
|
||||
expect(result).toEqual(mockRegistration);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('registers domain without optional nameservers', async () => {
|
||||
const registerRequest: DomainRegisterRequest = {
|
||||
domain: 'simple.com',
|
||||
years: 1,
|
||||
whois_privacy: false,
|
||||
auto_renew: false,
|
||||
contact: {
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
email: 'jane@example.com',
|
||||
phone: '+1.5559876543',
|
||||
address: '456 Oak Ave',
|
||||
city: 'Boston',
|
||||
state: 'MA',
|
||||
zip_code: '02101',
|
||||
country: 'US',
|
||||
},
|
||||
auto_configure: false,
|
||||
};
|
||||
|
||||
const mockRegistration: DomainRegistration = {
|
||||
id: 2,
|
||||
domain: 'simple.com',
|
||||
status: 'pending',
|
||||
registered_at: null,
|
||||
expires_at: null,
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: null,
|
||||
nameservers: [],
|
||||
days_until_expiry: null,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
|
||||
|
||||
const result = await registerDomain(registerRequest);
|
||||
|
||||
expect(result.whois_privacy).toBe(false);
|
||||
expect(result.auto_renew).toBe(false);
|
||||
expect(result.nameservers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRegisteredDomains', () => {
|
||||
it('fetches all registered domains for current business', async () => {
|
||||
const mockDomains: DomainRegistration[] = [
|
||||
{
|
||||
id: 1,
|
||||
domain: 'business1.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-15T10:00:00Z',
|
||||
expires_at: '2025-01-15T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
days_until_expiry: 365,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-01-15T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
domain: 'business2.net',
|
||||
status: 'active',
|
||||
registered_at: '2024-01-01T10:00:00Z',
|
||||
expires_at: '2024-03-01T10:00:00Z',
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 14.99,
|
||||
renewal_price: 16.99,
|
||||
nameservers: ['ns1.example.com', 'ns2.example.com'],
|
||||
days_until_expiry: 30,
|
||||
is_expiring_soon: true,
|
||||
created_at: '2024-01-01T09:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
|
||||
|
||||
const result = await getRegisteredDomains();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/');
|
||||
expect(result).toEqual(mockDomains);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].is_expiring_soon).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty domain list', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getRegisteredDomains();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDomainRegistration', () => {
|
||||
it('fetches a single domain registration by ID', async () => {
|
||||
const mockDomain: DomainRegistration = {
|
||||
id: 5,
|
||||
domain: 'example.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-06-01T10:00:00Z',
|
||||
expires_at: '2025-06-01T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'],
|
||||
days_until_expiry: 500,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-06-01T09:30:00Z',
|
||||
registrant_first_name: 'Alice',
|
||||
registrant_last_name: 'Johnson',
|
||||
registrant_email: 'alice@example.com',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
const result = await getDomainRegistration(5);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/5/');
|
||||
expect(result).toEqual(mockDomain);
|
||||
expect(result.registrant_email).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('fetches domain with failed status', async () => {
|
||||
const mockDomain: DomainRegistration = {
|
||||
id: 10,
|
||||
domain: 'failed.com',
|
||||
status: 'failed',
|
||||
registered_at: null,
|
||||
expires_at: null,
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: null,
|
||||
renewal_price: null,
|
||||
nameservers: [],
|
||||
days_until_expiry: null,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2024-01-10T10:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
|
||||
|
||||
const result = await getDomainRegistration(10);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.registered_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNameservers', () => {
|
||||
it('updates nameservers for a domain', async () => {
|
||||
const nameservers = [
|
||||
'ns1.customdns.com',
|
||||
'ns2.customdns.com',
|
||||
'ns3.customdns.com',
|
||||
'ns4.customdns.com',
|
||||
];
|
||||
const mockUpdated: DomainRegistration = {
|
||||
id: 3,
|
||||
domain: 'updated.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-01T10:00:00Z',
|
||||
expires_at: '2024-01-01T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: nameservers,
|
||||
days_until_expiry: 100,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
|
||||
|
||||
const result = await updateNameservers(3, nameservers);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/3/update_nameservers/', {
|
||||
nameservers: nameservers,
|
||||
});
|
||||
expect(result.nameservers).toEqual(nameservers);
|
||||
expect(result.nameservers).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('updates to default DigitalOcean nameservers', async () => {
|
||||
const nameservers = ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'];
|
||||
const mockUpdated: DomainRegistration = {
|
||||
id: 7,
|
||||
domain: 'reset.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-01T10:00:00Z',
|
||||
expires_at: '2024-01-01T10:00:00Z',
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: nameservers,
|
||||
days_until_expiry: 200,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
|
||||
|
||||
const result = await updateNameservers(7, nameservers);
|
||||
|
||||
expect(result.nameservers).toEqual(nameservers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleAutoRenew', () => {
|
||||
it('enables auto-renewal for a domain', async () => {
|
||||
const mockUpdated: DomainRegistration = {
|
||||
id: 4,
|
||||
domain: 'autorenew.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-01T10:00:00Z',
|
||||
expires_at: '2024-01-01T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
days_until_expiry: 150,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
|
||||
|
||||
const result = await toggleAutoRenew(4, true);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/4/toggle_auto_renew/', {
|
||||
auto_renew: true,
|
||||
});
|
||||
expect(result.auto_renew).toBe(true);
|
||||
});
|
||||
|
||||
it('disables auto-renewal for a domain', async () => {
|
||||
const mockUpdated: DomainRegistration = {
|
||||
id: 6,
|
||||
domain: 'noautorenew.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-01T10:00:00Z',
|
||||
expires_at: '2024-01-01T10:00:00Z',
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.example.com', 'ns2.example.com'],
|
||||
days_until_expiry: 60,
|
||||
is_expiring_soon: true,
|
||||
created_at: '2023-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
|
||||
|
||||
const result = await toggleAutoRenew(6, false);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/6/toggle_auto_renew/', {
|
||||
auto_renew: false,
|
||||
});
|
||||
expect(result.auto_renew).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renewDomain', () => {
|
||||
it('renews domain for 1 year (default)', async () => {
|
||||
const mockRenewed: DomainRegistration = {
|
||||
id: 8,
|
||||
domain: 'renew.com',
|
||||
status: 'active',
|
||||
registered_at: '2022-01-01T10:00:00Z',
|
||||
expires_at: '2025-01-01T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
days_until_expiry: 365,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2022-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
|
||||
|
||||
const result = await renewDomain(8);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/8/renew/', {
|
||||
years: 1,
|
||||
});
|
||||
expect(result).toEqual(mockRenewed);
|
||||
});
|
||||
|
||||
it('renews domain for multiple years', async () => {
|
||||
const mockRenewed: DomainRegistration = {
|
||||
id: 9,
|
||||
domain: 'longterm.com',
|
||||
status: 'active',
|
||||
registered_at: '2022-01-01T10:00:00Z',
|
||||
expires_at: '2027-01-01T10:00:00Z',
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.example.com', 'ns2.example.com'],
|
||||
days_until_expiry: 1095,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2022-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
|
||||
|
||||
const result = await renewDomain(9, 5);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/9/renew/', {
|
||||
years: 5,
|
||||
});
|
||||
expect(result).toEqual(mockRenewed);
|
||||
});
|
||||
|
||||
it('renews domain for 2 years', async () => {
|
||||
const mockRenewed: DomainRegistration = {
|
||||
id: 11,
|
||||
domain: 'twoyears.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-01-01T10:00:00Z',
|
||||
expires_at: '2026-01-01T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
|
||||
days_until_expiry: 730,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
|
||||
|
||||
const result = await renewDomain(11, 2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/11/renew/', {
|
||||
years: 2,
|
||||
});
|
||||
expect(result.expires_at).toBe('2026-01-01T10:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncDomain', () => {
|
||||
it('syncs domain information from NameSilo', async () => {
|
||||
const mockSynced: DomainRegistration = {
|
||||
id: 12,
|
||||
domain: 'synced.com',
|
||||
status: 'active',
|
||||
registered_at: '2023-05-15T10:00:00Z',
|
||||
expires_at: '2024-05-15T10:00:00Z',
|
||||
auto_renew: true,
|
||||
whois_privacy: true,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: ['ns1.namesilo.com', 'ns2.namesilo.com'],
|
||||
days_until_expiry: 120,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2023-05-15T09:30:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
|
||||
|
||||
const result = await syncDomain(12);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/12/sync/');
|
||||
expect(result).toEqual(mockSynced);
|
||||
});
|
||||
|
||||
it('syncs domain and updates status', async () => {
|
||||
const mockSynced: DomainRegistration = {
|
||||
id: 13,
|
||||
domain: 'expired.com',
|
||||
status: 'expired',
|
||||
registered_at: '2020-01-01T10:00:00Z',
|
||||
expires_at: '2023-01-01T10:00:00Z',
|
||||
auto_renew: false,
|
||||
whois_privacy: false,
|
||||
purchase_price: 12.99,
|
||||
renewal_price: 14.99,
|
||||
nameservers: [],
|
||||
days_until_expiry: -365,
|
||||
is_expiring_soon: false,
|
||||
created_at: '2020-01-01T09:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
|
||||
|
||||
const result = await syncDomain(13);
|
||||
|
||||
expect(result.status).toBe('expired');
|
||||
expect(result.days_until_expiry).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchHistory', () => {
|
||||
it('fetches domain search history', async () => {
|
||||
const mockHistory: DomainSearchHistory[] = [
|
||||
{
|
||||
id: 1,
|
||||
searched_domain: 'example.com',
|
||||
was_available: true,
|
||||
price: 12.99,
|
||||
searched_at: '2024-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
searched_domain: 'taken.com',
|
||||
was_available: false,
|
||||
price: null,
|
||||
searched_at: '2024-01-15T10:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
searched_domain: 'premium.com',
|
||||
was_available: true,
|
||||
price: 5000.0,
|
||||
searched_at: '2024-01-15T10:10:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
|
||||
|
||||
const result = await getSearchHistory();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/domains/history/');
|
||||
expect(result).toEqual(mockHistory);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1].was_available).toBe(false);
|
||||
expect(result[2].price).toBe(5000.0);
|
||||
});
|
||||
|
||||
it('handles empty search history', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getSearchHistory();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
877
frontend/src/api/__tests__/mfa.test.ts
Normal file
877
frontend/src/api/__tests__/mfa.test.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getMFAStatus,
|
||||
sendPhoneVerification,
|
||||
verifyPhone,
|
||||
enableSMSMFA,
|
||||
setupTOTP,
|
||||
verifyTOTPSetup,
|
||||
generateBackupCodes,
|
||||
getBackupCodesStatus,
|
||||
disableMFA,
|
||||
sendMFALoginCode,
|
||||
verifyMFALogin,
|
||||
listTrustedDevices,
|
||||
revokeTrustedDevice,
|
||||
revokeAllTrustedDevices,
|
||||
} from '../mfa';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('MFA API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MFA Status
|
||||
// ============================================================================
|
||||
|
||||
describe('getMFAStatus', () => {
|
||||
it('fetches MFA status from API', async () => {
|
||||
const mockStatus = {
|
||||
mfa_enabled: true,
|
||||
mfa_method: 'TOTP' as const,
|
||||
methods: ['TOTP' as const, 'BACKUP' as const],
|
||||
phone_last_4: '1234',
|
||||
phone_verified: true,
|
||||
totp_verified: true,
|
||||
backup_codes_count: 8,
|
||||
backup_codes_generated_at: '2024-01-01T00:00:00Z',
|
||||
trusted_devices_count: 2,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getMFAStatus();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/status/');
|
||||
expect(result).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it('returns status when MFA is disabled', async () => {
|
||||
const mockStatus = {
|
||||
mfa_enabled: false,
|
||||
mfa_method: 'NONE' as const,
|
||||
methods: [],
|
||||
phone_last_4: null,
|
||||
phone_verified: false,
|
||||
totp_verified: false,
|
||||
backup_codes_count: 0,
|
||||
backup_codes_generated_at: null,
|
||||
trusted_devices_count: 0,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getMFAStatus();
|
||||
|
||||
expect(result.mfa_enabled).toBe(false);
|
||||
expect(result.mfa_method).toBe('NONE');
|
||||
expect(result.methods).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns status with both SMS and TOTP enabled', async () => {
|
||||
const mockStatus = {
|
||||
mfa_enabled: true,
|
||||
mfa_method: 'BOTH' as const,
|
||||
methods: ['SMS' as const, 'TOTP' as const, 'BACKUP' as const],
|
||||
phone_last_4: '5678',
|
||||
phone_verified: true,
|
||||
totp_verified: true,
|
||||
backup_codes_count: 10,
|
||||
backup_codes_generated_at: '2024-01-15T12:00:00Z',
|
||||
trusted_devices_count: 3,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getMFAStatus();
|
||||
|
||||
expect(result.mfa_method).toBe('BOTH');
|
||||
expect(result.methods).toContain('SMS');
|
||||
expect(result.methods).toContain('TOTP');
|
||||
expect(result.methods).toContain('BACKUP');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SMS Setup
|
||||
// ============================================================================
|
||||
|
||||
describe('sendPhoneVerification', () => {
|
||||
it('sends phone verification code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Verification code sent to +1234567890',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sendPhoneVerification('+1234567890');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
|
||||
phone: '+1234567890',
|
||||
});
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles different phone number formats', async () => {
|
||||
const mockResponse = {
|
||||
data: { success: true, message: 'Code sent' },
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await sendPhoneVerification('555-123-4567');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
|
||||
phone: '555-123-4567',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyPhone', () => {
|
||||
it('verifies phone with valid code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Phone number verified successfully',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyPhone('123456');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/verify/', {
|
||||
code: '123456',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles verification failure', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Invalid verification code',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyPhone('000000');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableSMSMFA', () => {
|
||||
it('enables SMS MFA successfully', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'SMS MFA enabled successfully',
|
||||
mfa_method: 'SMS',
|
||||
backup_codes: ['code1', 'code2', 'code3'],
|
||||
backup_codes_message: 'Save these backup codes',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await enableSMSMFA();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/sms/enable/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mfa_method).toBe('SMS');
|
||||
expect(result.backup_codes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('enables SMS MFA without generating backup codes', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'SMS MFA enabled',
|
||||
mfa_method: 'SMS',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await enableSMSMFA();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backup_codes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TOTP Setup (Authenticator App)
|
||||
// ============================================================================
|
||||
|
||||
describe('setupTOTP', () => {
|
||||
it('initializes TOTP setup with QR code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
qr_code: 'data:image/png;base64,iVBORw0KGgoAAAANS...',
|
||||
provisioning_uri: 'otpauth://totp/SmoothSchedule:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SmoothSchedule',
|
||||
message: 'Scan the QR code with your authenticator app',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await setupTOTP();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.secret).toBe('JBSWY3DPEHPK3PXP');
|
||||
expect(result.qr_code).toContain('data:image/png');
|
||||
expect(result.provisioning_uri).toContain('otpauth://totp/');
|
||||
});
|
||||
|
||||
it('returns provisioning URI for manual entry', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
secret: 'SECRETKEY123',
|
||||
qr_code: 'data:image/png;base64,ABC...',
|
||||
provisioning_uri: 'otpauth://totp/App:user@test.com?secret=SECRETKEY123',
|
||||
message: 'Setup message',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await setupTOTP();
|
||||
|
||||
expect(result.provisioning_uri).toContain('SECRETKEY123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTOTPSetup', () => {
|
||||
it('verifies TOTP code and completes setup', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'TOTP authentication enabled successfully',
|
||||
mfa_method: 'TOTP',
|
||||
backup_codes: ['backup1', 'backup2', 'backup3', 'backup4', 'backup5'],
|
||||
backup_codes_message: 'Store these codes securely',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyTOTPSetup('123456');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', {
|
||||
code: '123456',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mfa_method).toBe('TOTP');
|
||||
expect(result.backup_codes).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('handles invalid TOTP code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Invalid TOTP code',
|
||||
mfa_method: '',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyTOTPSetup('000000');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Backup Codes
|
||||
// ============================================================================
|
||||
|
||||
describe('generateBackupCodes', () => {
|
||||
it('generates new backup codes', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
backup_codes: [
|
||||
'AAAA-BBBB-CCCC',
|
||||
'DDDD-EEEE-FFFF',
|
||||
'GGGG-HHHH-IIII',
|
||||
'JJJJ-KKKK-LLLL',
|
||||
'MMMM-NNNN-OOOO',
|
||||
'PPPP-QQQQ-RRRR',
|
||||
'SSSS-TTTT-UUUU',
|
||||
'VVVV-WWWW-XXXX',
|
||||
'YYYY-ZZZZ-1111',
|
||||
'2222-3333-4444',
|
||||
],
|
||||
message: 'Backup codes generated successfully',
|
||||
warning: 'Previous backup codes have been invalidated',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await generateBackupCodes();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backup_codes).toHaveLength(10);
|
||||
expect(result.warning).toContain('invalidated');
|
||||
});
|
||||
|
||||
it('generates codes in correct format', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
backup_codes: ['CODE-1234-ABCD', 'CODE-5678-EFGH'],
|
||||
message: 'Generated',
|
||||
warning: 'Old codes invalidated',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await generateBackupCodes();
|
||||
|
||||
result.backup_codes.forEach(code => {
|
||||
expect(code).toMatch(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackupCodesStatus', () => {
|
||||
it('returns backup codes status', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
count: 8,
|
||||
generated_at: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await getBackupCodesStatus();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
|
||||
expect(result.count).toBe(8);
|
||||
expect(result.generated_at).toBe('2024-01-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('returns status when no codes exist', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
count: 0,
|
||||
generated_at: null,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await getBackupCodesStatus();
|
||||
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.generated_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Disable MFA
|
||||
// ============================================================================
|
||||
|
||||
describe('disableMFA', () => {
|
||||
it('disables MFA with password', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'MFA has been disabled',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await disableMFA({ password: 'mypassword123' });
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
|
||||
password: 'mypassword123',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('disabled');
|
||||
});
|
||||
|
||||
it('disables MFA with valid MFA code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'MFA disabled successfully',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await disableMFA({ mfa_code: '123456' });
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
|
||||
mfa_code: '123456',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles both password and MFA code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'MFA disabled',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await disableMFA({ password: 'pass', mfa_code: '654321' });
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
|
||||
password: 'pass',
|
||||
mfa_code: '654321',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles incorrect credentials', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Invalid password or MFA code',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await disableMFA({ password: 'wrongpass' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MFA Login Challenge
|
||||
// ============================================================================
|
||||
|
||||
describe('sendMFALoginCode', () => {
|
||||
it('sends SMS code for login', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Verification code sent to your phone',
|
||||
method: 'SMS',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sendMFALoginCode(42, 'SMS');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
|
||||
user_id: 42,
|
||||
method: 'SMS',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.method).toBe('SMS');
|
||||
});
|
||||
|
||||
it('defaults to SMS method when not specified', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Code sent',
|
||||
method: 'SMS',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await sendMFALoginCode(123);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
|
||||
user_id: 123,
|
||||
method: 'SMS',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends TOTP method (no actual code sent)', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Use your authenticator app',
|
||||
method: 'TOTP',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sendMFALoginCode(99, 'TOTP');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
|
||||
user_id: 99,
|
||||
method: 'TOTP',
|
||||
});
|
||||
expect(result.method).toBe('TOTP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMFALogin', () => {
|
||||
it('verifies MFA code and completes login', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
access: 'access-token-xyz',
|
||||
refresh: 'refresh-token-abc',
|
||||
user: {
|
||||
id: 42,
|
||||
email: 'user@example.com',
|
||||
username: 'john_doe',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
full_name: 'John Doe',
|
||||
role: 'owner',
|
||||
business_subdomain: 'business1',
|
||||
mfa_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyMFALogin(42, '123456', 'TOTP', false);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
|
||||
user_id: 42,
|
||||
code: '123456',
|
||||
method: 'TOTP',
|
||||
trust_device: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.access).toBe('access-token-xyz');
|
||||
expect(result.user.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('verifies SMS code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
access: 'token1',
|
||||
refresh: 'token2',
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'test@test.com',
|
||||
username: 'test',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
full_name: 'Test User',
|
||||
role: 'staff',
|
||||
business_subdomain: null,
|
||||
mfa_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyMFALogin(1, '654321', 'SMS');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
|
||||
user_id: 1,
|
||||
code: '654321',
|
||||
method: 'SMS',
|
||||
trust_device: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('verifies backup code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
access: 'token-a',
|
||||
refresh: 'token-b',
|
||||
user: {
|
||||
id: 5,
|
||||
email: 'backup@test.com',
|
||||
username: 'backup_user',
|
||||
first_name: 'Backup',
|
||||
last_name: 'Test',
|
||||
full_name: 'Backup Test',
|
||||
role: 'manager',
|
||||
business_subdomain: 'company',
|
||||
mfa_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyMFALogin(5, 'AAAA-BBBB-CCCC', 'BACKUP');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
|
||||
user_id: 5,
|
||||
code: 'AAAA-BBBB-CCCC',
|
||||
method: 'BACKUP',
|
||||
trust_device: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts device after successful verification', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
access: 'trusted-access',
|
||||
refresh: 'trusted-refresh',
|
||||
user: {
|
||||
id: 10,
|
||||
email: 'trusted@example.com',
|
||||
username: 'trusted',
|
||||
first_name: 'Trusted',
|
||||
last_name: 'User',
|
||||
full_name: 'Trusted User',
|
||||
role: 'owner',
|
||||
business_subdomain: 'trusted-biz',
|
||||
mfa_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await verifyMFALogin(10, '999888', 'TOTP', true);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
|
||||
user_id: 10,
|
||||
code: '999888',
|
||||
method: 'TOTP',
|
||||
trust_device: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults trustDevice to false', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
access: 'a',
|
||||
refresh: 'b',
|
||||
user: {
|
||||
id: 1,
|
||||
email: 'e@e.com',
|
||||
username: 'u',
|
||||
first_name: 'F',
|
||||
last_name: 'L',
|
||||
full_name: 'F L',
|
||||
role: 'staff',
|
||||
business_subdomain: null,
|
||||
mfa_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await verifyMFALogin(1, '111111', 'SMS');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
|
||||
user_id: 1,
|
||||
code: '111111',
|
||||
method: 'SMS',
|
||||
trust_device: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles invalid MFA code', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: false,
|
||||
access: '',
|
||||
refresh: '',
|
||||
user: {
|
||||
id: 0,
|
||||
email: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
full_name: '',
|
||||
role: '',
|
||||
business_subdomain: null,
|
||||
mfa_enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await verifyMFALogin(1, 'invalid', 'TOTP');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Trusted Devices
|
||||
// ============================================================================
|
||||
|
||||
describe('listTrustedDevices', () => {
|
||||
it('lists all trusted devices', async () => {
|
||||
const mockDevices = {
|
||||
devices: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chrome on Windows',
|
||||
ip_address: '192.168.1.100',
|
||||
created_at: '2024-01-01T10:00:00Z',
|
||||
last_used_at: '2024-01-15T14:30:00Z',
|
||||
expires_at: '2024-02-01T10:00:00Z',
|
||||
is_current: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Safari on iPhone',
|
||||
ip_address: '192.168.1.101',
|
||||
created_at: '2024-01-05T12:00:00Z',
|
||||
last_used_at: '2024-01-14T09:15:00Z',
|
||||
expires_at: '2024-02-05T12:00:00Z',
|
||||
is_current: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
|
||||
|
||||
const result = await listTrustedDevices();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/devices/');
|
||||
expect(result.devices).toHaveLength(2);
|
||||
expect(result.devices[0].is_current).toBe(true);
|
||||
expect(result.devices[1].name).toBe('Safari on iPhone');
|
||||
});
|
||||
|
||||
it('returns empty list when no devices', async () => {
|
||||
const mockDevices = { devices: [] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
|
||||
|
||||
const result = await listTrustedDevices();
|
||||
|
||||
expect(result.devices).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes device metadata', async () => {
|
||||
const mockDevices = {
|
||||
devices: [
|
||||
{
|
||||
id: 99,
|
||||
name: 'Firefox on Linux',
|
||||
ip_address: '10.0.0.50',
|
||||
created_at: '2024-01-10T08:00:00Z',
|
||||
last_used_at: '2024-01-16T16:45:00Z',
|
||||
expires_at: '2024-02-10T08:00:00Z',
|
||||
is_current: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
|
||||
|
||||
const result = await listTrustedDevices();
|
||||
|
||||
const device = result.devices[0];
|
||||
expect(device.id).toBe(99);
|
||||
expect(device.name).toBe('Firefox on Linux');
|
||||
expect(device.ip_address).toBe('10.0.0.50');
|
||||
expect(device.created_at).toBeTruthy();
|
||||
expect(device.last_used_at).toBeTruthy();
|
||||
expect(device.expires_at).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeTrustedDevice', () => {
|
||||
it('revokes a specific device', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Device revoked successfully',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await revokeTrustedDevice(42);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/42/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('revoked');
|
||||
});
|
||||
|
||||
it('handles different device IDs', async () => {
|
||||
const mockResponse = {
|
||||
data: { success: true, message: 'Revoked' },
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
await revokeTrustedDevice(999);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/999/');
|
||||
});
|
||||
|
||||
it('handles device not found', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: false,
|
||||
message: 'Device not found',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await revokeTrustedDevice(0);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAllTrustedDevices', () => {
|
||||
it('revokes all trusted devices', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'All devices revoked successfully',
|
||||
count: 5,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await revokeAllTrustedDevices();
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/revoke-all/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBe(5);
|
||||
expect(result.message).toContain('All devices revoked');
|
||||
});
|
||||
|
||||
it('returns zero count when no devices to revoke', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'No devices to revoke',
|
||||
count: 0,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await revokeAllTrustedDevices();
|
||||
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
it('includes count of revoked devices', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Devices revoked',
|
||||
count: 12,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await revokeAllTrustedDevices();
|
||||
|
||||
expect(result.count).toBe(12);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
113
frontend/src/api/__tests__/notifications.test.ts
Normal file
113
frontend/src/api/__tests__/notifications.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
clearAllNotifications,
|
||||
} from '../notifications';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('notifications API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getNotifications', () => {
|
||||
it('fetches all notifications without params', async () => {
|
||||
const mockNotifications = [
|
||||
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
|
||||
{ id: 2, verb: 'updated', read: true, timestamp: '2024-01-02T00:00:00Z' },
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNotifications });
|
||||
|
||||
const result = await getNotifications();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/notifications/');
|
||||
expect(result).toEqual(mockNotifications);
|
||||
});
|
||||
|
||||
it('applies read filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getNotifications({ read: false });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=false');
|
||||
});
|
||||
|
||||
it('applies limit parameter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getNotifications({ limit: 10 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?limit=10');
|
||||
});
|
||||
|
||||
it('applies multiple parameters', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getNotifications({ read: true, limit: 5 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=true&limit=5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('returns unread count', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 5 } });
|
||||
|
||||
const result = await getUnreadCount();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/notifications/unread_count/');
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('returns 0 when no unread notifications', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 0 } });
|
||||
|
||||
const result = await getUnreadCount();
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markNotificationRead', () => {
|
||||
it('marks single notification as read', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await markNotificationRead(42);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/notifications/42/mark_read/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllNotificationsRead', () => {
|
||||
it('marks all notifications as read', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await markAllNotificationsRead();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/notifications/mark_all_read/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllNotifications', () => {
|
||||
it('clears all read notifications', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await clearAllNotifications();
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/notifications/clear_all/');
|
||||
});
|
||||
});
|
||||
});
|
||||
441
frontend/src/api/__tests__/oauth.test.ts
Normal file
441
frontend/src/api/__tests__/oauth.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getOAuthProviders,
|
||||
initiateOAuth,
|
||||
handleOAuthCallback,
|
||||
getOAuthConnections,
|
||||
disconnectOAuth,
|
||||
} from '../oauth';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('oauth API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOAuthProviders', () => {
|
||||
it('fetches list of enabled OAuth providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
name: 'google',
|
||||
display_name: 'Google',
|
||||
icon: 'google-icon.svg',
|
||||
},
|
||||
{
|
||||
name: 'microsoft',
|
||||
display_name: 'Microsoft',
|
||||
icon: 'microsoft-icon.svg',
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
display_name: 'GitHub',
|
||||
icon: 'github-icon.svg',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { providers: mockProviders },
|
||||
});
|
||||
|
||||
const result = await getOAuthProviders();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/providers/');
|
||||
expect(result).toEqual(mockProviders);
|
||||
});
|
||||
|
||||
it('returns empty array when no providers enabled', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { providers: [] },
|
||||
});
|
||||
|
||||
const result = await getOAuthProviders();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts providers from nested response', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
name: 'google',
|
||||
display_name: 'Google',
|
||||
icon: 'google-icon.svg',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { providers: mockProviders },
|
||||
});
|
||||
|
||||
const result = await getOAuthProviders();
|
||||
|
||||
// Verify it returns response.data.providers, not response.data
|
||||
expect(result).toEqual(mockProviders);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiateOAuth', () => {
|
||||
it('initiates OAuth flow for Google', async () => {
|
||||
const mockResponse = {
|
||||
authorization_url: 'https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=...',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await initiateOAuth('google');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/google/authorize/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.authorization_url).toContain('accounts.google.com');
|
||||
});
|
||||
|
||||
it('initiates OAuth flow for Microsoft', async () => {
|
||||
const mockResponse = {
|
||||
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await initiateOAuth('microsoft');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/microsoft/authorize/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('initiates OAuth flow for GitHub', async () => {
|
||||
const mockResponse = {
|
||||
authorization_url: 'https://github.com/login/oauth/authorize?client_id=xyz&scope=...',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await initiateOAuth('github');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/github/authorize/');
|
||||
expect(result.authorization_url).toContain('github.com');
|
||||
});
|
||||
|
||||
it('includes state parameter in authorization URL', async () => {
|
||||
const mockResponse = {
|
||||
authorization_url: 'https://provider.com/auth?state=random-state-token',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await initiateOAuth('google');
|
||||
|
||||
expect(result.authorization_url).toContain('state=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOAuthCallback', () => {
|
||||
it('exchanges authorization code for tokens', async () => {
|
||||
const mockResponse = {
|
||||
access: 'access-token-123',
|
||||
refresh: 'refresh-token-456',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe',
|
||||
role: 'owner',
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await handleOAuthCallback('google', 'auth-code-xyz', 'state-token-abc');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/google/callback/', {
|
||||
code: 'auth-code-xyz',
|
||||
state: 'state-token-abc',
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.access).toBe('access-token-123');
|
||||
expect(result.refresh).toBe('refresh-token-456');
|
||||
expect(result.user.email).toBe('john@example.com');
|
||||
});
|
||||
|
||||
it('handles callback with business user', async () => {
|
||||
const mockResponse = {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 2,
|
||||
username: 'staffmember',
|
||||
email: 'staff@business.com',
|
||||
name: 'Staff Member',
|
||||
role: 'staff',
|
||||
is_staff: true,
|
||||
is_superuser: false,
|
||||
business: 5,
|
||||
business_name: 'My Business',
|
||||
business_subdomain: 'mybiz',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await handleOAuthCallback('microsoft', 'code-123', 'state-456');
|
||||
|
||||
expect(result.user.business).toBe(5);
|
||||
expect(result.user.business_name).toBe('My Business');
|
||||
expect(result.user.business_subdomain).toBe('mybiz');
|
||||
});
|
||||
|
||||
it('handles callback with avatar URL', async () => {
|
||||
const mockResponse = {
|
||||
access: 'access-token',
|
||||
refresh: 'refresh-token',
|
||||
user: {
|
||||
id: 3,
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
name: 'User Name',
|
||||
role: 'customer',
|
||||
avatar_url: 'https://avatar.com/user.jpg',
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await handleOAuthCallback('github', 'code-abc', 'state-def');
|
||||
|
||||
expect(result.user.avatar_url).toBe('https://avatar.com/user.jpg');
|
||||
});
|
||||
|
||||
it('handles superuser login via OAuth', async () => {
|
||||
const mockResponse = {
|
||||
access: 'admin-access-token',
|
||||
refresh: 'admin-refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@platform.com',
|
||||
name: 'Platform Admin',
|
||||
role: 'superuser',
|
||||
is_staff: true,
|
||||
is_superuser: true,
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await handleOAuthCallback('google', 'admin-code', 'admin-state');
|
||||
|
||||
expect(result.user.is_superuser).toBe(true);
|
||||
expect(result.user.role).toBe('superuser');
|
||||
});
|
||||
|
||||
it('sends correct data for different providers', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
data: {
|
||||
access: 'token',
|
||||
refresh: 'token',
|
||||
user: { id: 1, email: 'test@test.com', name: 'Test', role: 'owner', is_staff: false, is_superuser: false, username: 'test' },
|
||||
},
|
||||
});
|
||||
|
||||
await handleOAuthCallback('github', 'code-1', 'state-1');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/github/callback/', {
|
||||
code: 'code-1',
|
||||
state: 'state-1',
|
||||
});
|
||||
|
||||
await handleOAuthCallback('microsoft', 'code-2', 'state-2');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/microsoft/callback/', {
|
||||
code: 'code-2',
|
||||
state: 'state-2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOAuthConnections', () => {
|
||||
it('fetches list of connected OAuth accounts', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
provider: 'google',
|
||||
provider_user_id: 'google-user-123',
|
||||
email: 'user@gmail.com',
|
||||
connected_at: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
provider: 'microsoft',
|
||||
provider_user_id: 'ms-user-456',
|
||||
email: 'user@outlook.com',
|
||||
connected_at: '2024-02-20T14:45:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { connections: mockConnections },
|
||||
});
|
||||
|
||||
const result = await getOAuthConnections();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/connections/');
|
||||
expect(result).toEqual(mockConnections);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no connections exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { connections: [] },
|
||||
});
|
||||
|
||||
const result = await getOAuthConnections();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts connections from nested response', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
provider: 'github',
|
||||
provider_user_id: 'github-123',
|
||||
connected_at: '2024-03-01T09:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { connections: mockConnections },
|
||||
});
|
||||
|
||||
const result = await getOAuthConnections();
|
||||
|
||||
// Verify it returns response.data.connections, not response.data
|
||||
expect(result).toEqual(mockConnections);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles connections without email field', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
provider: 'github',
|
||||
provider_user_id: 'github-user-789',
|
||||
connected_at: '2024-04-10T12:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { connections: mockConnections },
|
||||
});
|
||||
|
||||
const result = await getOAuthConnections();
|
||||
|
||||
expect(result[0].email).toBeUndefined();
|
||||
expect(result[0].provider).toBe('github');
|
||||
});
|
||||
|
||||
it('handles multiple connections from same provider', async () => {
|
||||
const mockConnections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
provider: 'google',
|
||||
provider_user_id: 'google-user-1',
|
||||
email: 'work@gmail.com',
|
||||
connected_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'conn-2',
|
||||
provider: 'google',
|
||||
provider_user_id: 'google-user-2',
|
||||
email: 'personal@gmail.com',
|
||||
connected_at: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { connections: mockConnections },
|
||||
});
|
||||
|
||||
const result = await getOAuthConnections();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.filter((c) => c.provider === 'google')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnectOAuth', () => {
|
||||
it('disconnects Google OAuth account', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await disconnectOAuth('google');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/google/');
|
||||
});
|
||||
|
||||
it('disconnects Microsoft OAuth account', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await disconnectOAuth('microsoft');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/microsoft/');
|
||||
});
|
||||
|
||||
it('disconnects GitHub OAuth account', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await disconnectOAuth('github');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/github/');
|
||||
});
|
||||
|
||||
it('returns void on successful disconnect', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
const result = await disconnectOAuth('google');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles disconnect for custom provider', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await disconnectOAuth('custom-provider');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/custom-provider/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('propagates errors from getOAuthProviders', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(error);
|
||||
|
||||
await expect(getOAuthProviders()).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('propagates errors from initiateOAuth', async () => {
|
||||
const error = new Error('Provider not configured');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(error);
|
||||
|
||||
await expect(initiateOAuth('google')).rejects.toThrow('Provider not configured');
|
||||
});
|
||||
|
||||
it('propagates errors from handleOAuthCallback', async () => {
|
||||
const error = new Error('Invalid authorization code');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(error);
|
||||
|
||||
await expect(handleOAuthCallback('google', 'bad-code', 'state')).rejects.toThrow('Invalid authorization code');
|
||||
});
|
||||
|
||||
it('propagates errors from getOAuthConnections', async () => {
|
||||
const error = new Error('Unauthorized');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(error);
|
||||
|
||||
await expect(getOAuthConnections()).rejects.toThrow('Unauthorized');
|
||||
});
|
||||
|
||||
it('propagates errors from disconnectOAuth', async () => {
|
||||
const error = new Error('Connection not found');
|
||||
vi.mocked(apiClient.delete).mockRejectedValue(error);
|
||||
|
||||
await expect(disconnectOAuth('google')).rejects.toThrow('Connection not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
1031
frontend/src/api/__tests__/payments.test.ts
Normal file
1031
frontend/src/api/__tests__/payments.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
989
frontend/src/api/__tests__/platform.test.ts
Normal file
989
frontend/src/api/__tests__/platform.test.ts
Normal file
@@ -0,0 +1,989 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getBusinesses,
|
||||
updateBusiness,
|
||||
createBusiness,
|
||||
deleteBusiness,
|
||||
getUsers,
|
||||
getBusinessUsers,
|
||||
verifyUserEmail,
|
||||
getTenantInvitations,
|
||||
createTenantInvitation,
|
||||
resendTenantInvitation,
|
||||
cancelTenantInvitation,
|
||||
getInvitationByToken,
|
||||
acceptInvitation,
|
||||
type PlatformBusiness,
|
||||
type PlatformBusinessUpdate,
|
||||
type PlatformBusinessCreate,
|
||||
type PlatformUser,
|
||||
type TenantInvitation,
|
||||
type TenantInvitationCreate,
|
||||
type TenantInvitationDetail,
|
||||
type TenantInvitationAccept,
|
||||
} from '../platform';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('platform API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Business Management
|
||||
// ============================================================================
|
||||
|
||||
describe('getBusinesses', () => {
|
||||
it('fetches all businesses from API', async () => {
|
||||
const mockBusinesses: PlatformBusiness[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Acme Corp',
|
||||
subdomain: 'acme',
|
||||
tier: 'PROFESSIONAL',
|
||||
is_active: true,
|
||||
created_on: '2025-01-01T00:00:00Z',
|
||||
user_count: 5,
|
||||
owner: {
|
||||
id: 10,
|
||||
username: 'john',
|
||||
full_name: 'John Doe',
|
||||
email: 'john@acme.com',
|
||||
role: 'owner',
|
||||
email_verified: true,
|
||||
},
|
||||
max_users: 20,
|
||||
max_resources: 50,
|
||||
contact_email: 'contact@acme.com',
|
||||
phone: '555-1234',
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Beta LLC',
|
||||
subdomain: 'beta',
|
||||
tier: 'STARTER',
|
||||
is_active: true,
|
||||
created_on: '2025-01-02T00:00:00Z',
|
||||
user_count: 2,
|
||||
owner: null,
|
||||
max_users: 5,
|
||||
max_resources: 10,
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusinesses });
|
||||
|
||||
const result = await getBusinesses();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/businesses/');
|
||||
expect(result).toEqual(mockBusinesses);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Acme Corp');
|
||||
expect(result[1].owner).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array when no businesses exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getBusinesses();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBusiness', () => {
|
||||
it('updates a business with full data', async () => {
|
||||
const businessId = 1;
|
||||
const updateData: PlatformBusinessUpdate = {
|
||||
name: 'Updated Name',
|
||||
is_active: false,
|
||||
subscription_tier: 'ENTERPRISE',
|
||||
max_users: 100,
|
||||
max_resources: 500,
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 1,
|
||||
name: 'Updated Name',
|
||||
subdomain: 'acme',
|
||||
tier: 'ENTERPRISE',
|
||||
is_active: false,
|
||||
created_on: '2025-01-01T00:00:00Z',
|
||||
user_count: 5,
|
||||
owner: null,
|
||||
max_users: 100,
|
||||
max_resources: 500,
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateBusiness(businessId, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||
'/platform/businesses/1/',
|
||||
updateData
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.name).toBe('Updated Name');
|
||||
expect(result.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('updates a business with partial data', async () => {
|
||||
const businessId = 2;
|
||||
const updateData: PlatformBusinessUpdate = {
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 2,
|
||||
name: 'Beta LLC',
|
||||
subdomain: 'beta',
|
||||
tier: 'STARTER',
|
||||
is_active: true,
|
||||
created_on: '2025-01-02T00:00:00Z',
|
||||
user_count: 2,
|
||||
owner: null,
|
||||
max_users: 5,
|
||||
max_resources: 10,
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateBusiness(businessId, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||
'/platform/businesses/2/',
|
||||
updateData
|
||||
);
|
||||
expect(result.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('updates only specific permissions', async () => {
|
||||
const businessId = 3;
|
||||
const updateData: PlatformBusinessUpdate = {
|
||||
can_accept_payments: true,
|
||||
can_api_access: true,
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 3,
|
||||
name: 'Gamma Inc',
|
||||
subdomain: 'gamma',
|
||||
tier: 'PROFESSIONAL',
|
||||
is_active: true,
|
||||
created_on: '2025-01-03T00:00:00Z',
|
||||
user_count: 10,
|
||||
owner: null,
|
||||
max_users: 20,
|
||||
max_resources: 50,
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: true,
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await updateBusiness(businessId, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith(
|
||||
'/platform/businesses/3/',
|
||||
updateData
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBusiness', () => {
|
||||
it('creates a business with minimal data', async () => {
|
||||
const createData: PlatformBusinessCreate = {
|
||||
name: 'New Business',
|
||||
subdomain: 'newbiz',
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 10,
|
||||
name: 'New Business',
|
||||
subdomain: 'newbiz',
|
||||
tier: 'FREE',
|
||||
is_active: true,
|
||||
created_on: '2025-01-15T00:00:00Z',
|
||||
user_count: 0,
|
||||
owner: null,
|
||||
max_users: 3,
|
||||
max_resources: 5,
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createBusiness(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/businesses/',
|
||||
createData
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.id).toBe(10);
|
||||
expect(result.subdomain).toBe('newbiz');
|
||||
});
|
||||
|
||||
it('creates a business with full data including owner', async () => {
|
||||
const createData: PlatformBusinessCreate = {
|
||||
name: 'Premium Business',
|
||||
subdomain: 'premium',
|
||||
subscription_tier: 'ENTERPRISE',
|
||||
is_active: true,
|
||||
max_users: 100,
|
||||
max_resources: 500,
|
||||
contact_email: 'contact@premium.com',
|
||||
phone: '555-9999',
|
||||
can_manage_oauth_credentials: true,
|
||||
owner_email: 'owner@premium.com',
|
||||
owner_name: 'Jane Smith',
|
||||
owner_password: 'secure-password',
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 11,
|
||||
name: 'Premium Business',
|
||||
subdomain: 'premium',
|
||||
tier: 'ENTERPRISE',
|
||||
is_active: true,
|
||||
created_on: '2025-01-15T10:00:00Z',
|
||||
user_count: 1,
|
||||
owner: {
|
||||
id: 20,
|
||||
username: 'owner@premium.com',
|
||||
full_name: 'Jane Smith',
|
||||
email: 'owner@premium.com',
|
||||
role: 'owner',
|
||||
email_verified: false,
|
||||
},
|
||||
max_users: 100,
|
||||
max_resources: 500,
|
||||
contact_email: 'contact@premium.com',
|
||||
phone: '555-9999',
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createBusiness(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/businesses/',
|
||||
createData
|
||||
);
|
||||
expect(result.owner).not.toBeNull();
|
||||
expect(result.owner?.email).toBe('owner@premium.com');
|
||||
});
|
||||
|
||||
it('creates a business with custom tier and limits', async () => {
|
||||
const createData: PlatformBusinessCreate = {
|
||||
name: 'Custom Business',
|
||||
subdomain: 'custom',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
max_users: 50,
|
||||
max_resources: 100,
|
||||
};
|
||||
|
||||
const mockResponse: PlatformBusiness = {
|
||||
id: 12,
|
||||
name: 'Custom Business',
|
||||
subdomain: 'custom',
|
||||
tier: 'PROFESSIONAL',
|
||||
is_active: true,
|
||||
created_on: '2025-01-15T12:00:00Z',
|
||||
user_count: 0,
|
||||
owner: null,
|
||||
max_users: 50,
|
||||
max_resources: 100,
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: true,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createBusiness(createData);
|
||||
|
||||
expect(result.max_users).toBe(50);
|
||||
expect(result.max_resources).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBusiness', () => {
|
||||
it('deletes a business by ID', async () => {
|
||||
const businessId = 5;
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await deleteBusiness(businessId);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/5/');
|
||||
});
|
||||
|
||||
it('handles deletion with no response data', async () => {
|
||||
const businessId = 10;
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
const result = await deleteBusiness(businessId);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/10/');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// User Management
|
||||
// ============================================================================
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('fetches all users from API', async () => {
|
||||
const mockUsers: PlatformUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'admin@platform.com',
|
||||
username: 'admin',
|
||||
name: 'Platform Admin',
|
||||
role: 'superuser',
|
||||
is_active: true,
|
||||
is_staff: true,
|
||||
is_superuser: true,
|
||||
email_verified: true,
|
||||
business: null,
|
||||
date_joined: '2024-01-01T00:00:00Z',
|
||||
last_login: '2025-01-15T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'user@acme.com',
|
||||
username: 'user1',
|
||||
name: 'Acme User',
|
||||
role: 'staff',
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
email_verified: true,
|
||||
business: 1,
|
||||
business_name: 'Acme Corp',
|
||||
business_subdomain: 'acme',
|
||||
date_joined: '2024-06-01T00:00:00Z',
|
||||
last_login: '2025-01-14T15:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
email: 'inactive@example.com',
|
||||
username: 'inactive',
|
||||
is_active: false,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
email_verified: false,
|
||||
business: null,
|
||||
date_joined: '2024-03-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
|
||||
|
||||
const result = await getUsers();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].is_superuser).toBe(true);
|
||||
expect(result[1].business_name).toBe('Acme Corp');
|
||||
expect(result[2].is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty array when no users exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBusinessUsers', () => {
|
||||
it('fetches users for a specific business', async () => {
|
||||
const businessId = 1;
|
||||
const mockUsers: PlatformUser[] = [
|
||||
{
|
||||
id: 10,
|
||||
email: 'owner@acme.com',
|
||||
username: 'owner',
|
||||
name: 'John Doe',
|
||||
role: 'owner',
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
email_verified: true,
|
||||
business: 1,
|
||||
business_name: 'Acme Corp',
|
||||
business_subdomain: 'acme',
|
||||
date_joined: '2024-01-01T00:00:00Z',
|
||||
last_login: '2025-01-15T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
email: 'staff@acme.com',
|
||||
username: 'staff1',
|
||||
name: 'Jane Smith',
|
||||
role: 'staff',
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
email_verified: true,
|
||||
business: 1,
|
||||
business_name: 'Acme Corp',
|
||||
business_subdomain: 'acme',
|
||||
date_joined: '2024-03-01T00:00:00Z',
|
||||
last_login: '2025-01-14T16:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
|
||||
|
||||
const result = await getBusinessUsers(businessId);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=1');
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every(u => u.business === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array when business has no users', async () => {
|
||||
const businessId = 99;
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getBusinessUsers(businessId);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=99');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles different business IDs correctly', async () => {
|
||||
const businessId = 5;
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getBusinessUsers(businessId);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyUserEmail', () => {
|
||||
it('verifies a user email by ID', async () => {
|
||||
const userId = 10;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await verifyUserEmail(userId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/10/verify_email/');
|
||||
});
|
||||
|
||||
it('handles verification with no response data', async () => {
|
||||
const userId = 25;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
|
||||
|
||||
const result = await verifyUserEmail(userId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/25/verify_email/');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Invitations
|
||||
// ============================================================================
|
||||
|
||||
describe('getTenantInvitations', () => {
|
||||
it('fetches all tenant invitations from API', async () => {
|
||||
const mockInvitations: TenantInvitation[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'newclient@example.com',
|
||||
token: 'abc123token',
|
||||
status: 'PENDING',
|
||||
suggested_business_name: 'New Client Corp',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
custom_max_users: 50,
|
||||
custom_max_resources: 100,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: true,
|
||||
},
|
||||
personal_message: 'Welcome to our platform!',
|
||||
invited_by: 1,
|
||||
invited_by_email: 'admin@platform.com',
|
||||
created_at: '2025-01-10T10:00:00Z',
|
||||
expires_at: '2025-01-24T10:00:00Z',
|
||||
accepted_at: null,
|
||||
created_tenant: null,
|
||||
created_tenant_name: null,
|
||||
created_user: null,
|
||||
created_user_email: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'accepted@example.com',
|
||||
token: 'xyz789token',
|
||||
status: 'ACCEPTED',
|
||||
suggested_business_name: 'Accepted Business',
|
||||
subscription_tier: 'STARTER',
|
||||
custom_max_users: null,
|
||||
custom_max_resources: null,
|
||||
permissions: {},
|
||||
personal_message: '',
|
||||
invited_by: 1,
|
||||
invited_by_email: 'admin@platform.com',
|
||||
created_at: '2025-01-05T08:00:00Z',
|
||||
expires_at: '2025-01-19T08:00:00Z',
|
||||
accepted_at: '2025-01-06T12:00:00Z',
|
||||
created_tenant: 5,
|
||||
created_tenant_name: 'Accepted Business',
|
||||
created_user: 15,
|
||||
created_user_email: 'accepted@example.com',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
|
||||
|
||||
const result = await getTenantInvitations();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/');
|
||||
expect(result).toEqual(mockInvitations);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].status).toBe('PENDING');
|
||||
expect(result[1].status).toBe('ACCEPTED');
|
||||
});
|
||||
|
||||
it('returns empty array when no invitations exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getTenantInvitations();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTenantInvitation', () => {
|
||||
it('creates a tenant invitation with minimal data', async () => {
|
||||
const createData: TenantInvitationCreate = {
|
||||
email: 'client@example.com',
|
||||
subscription_tier: 'STARTER',
|
||||
};
|
||||
|
||||
const mockResponse: TenantInvitation = {
|
||||
id: 10,
|
||||
email: 'client@example.com',
|
||||
token: 'generated-token-123',
|
||||
status: 'PENDING',
|
||||
suggested_business_name: '',
|
||||
subscription_tier: 'STARTER',
|
||||
custom_max_users: null,
|
||||
custom_max_resources: null,
|
||||
permissions: {},
|
||||
personal_message: '',
|
||||
invited_by: 1,
|
||||
invited_by_email: 'admin@platform.com',
|
||||
created_at: '2025-01-15T14:00:00Z',
|
||||
expires_at: '2025-01-29T14:00:00Z',
|
||||
accepted_at: null,
|
||||
created_tenant: null,
|
||||
created_tenant_name: null,
|
||||
created_user: null,
|
||||
created_user_email: null,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createTenantInvitation(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/',
|
||||
createData
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.email).toBe('client@example.com');
|
||||
expect(result.status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('creates a tenant invitation with full data', async () => {
|
||||
const createData: TenantInvitationCreate = {
|
||||
email: 'vip@example.com',
|
||||
suggested_business_name: 'VIP Corp',
|
||||
subscription_tier: 'ENTERPRISE',
|
||||
custom_max_users: 500,
|
||||
custom_max_resources: 1000,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
},
|
||||
personal_message: 'Welcome to our premium tier!',
|
||||
};
|
||||
|
||||
const mockResponse: TenantInvitation = {
|
||||
id: 11,
|
||||
email: 'vip@example.com',
|
||||
token: 'vip-token-456',
|
||||
status: 'PENDING',
|
||||
suggested_business_name: 'VIP Corp',
|
||||
subscription_tier: 'ENTERPRISE',
|
||||
custom_max_users: 500,
|
||||
custom_max_resources: 1000,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
},
|
||||
personal_message: 'Welcome to our premium tier!',
|
||||
invited_by: 1,
|
||||
invited_by_email: 'admin@platform.com',
|
||||
created_at: '2025-01-15T15:00:00Z',
|
||||
expires_at: '2025-01-29T15:00:00Z',
|
||||
accepted_at: null,
|
||||
created_tenant: null,
|
||||
created_tenant_name: null,
|
||||
created_user: null,
|
||||
created_user_email: null,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createTenantInvitation(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/',
|
||||
createData
|
||||
);
|
||||
expect(result.suggested_business_name).toBe('VIP Corp');
|
||||
expect(result.custom_max_users).toBe(500);
|
||||
expect(result.permissions.can_white_label).toBe(true);
|
||||
});
|
||||
|
||||
it('creates invitation with partial permissions', async () => {
|
||||
const createData: TenantInvitationCreate = {
|
||||
email: 'partial@example.com',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
permissions: {
|
||||
can_accept_payments: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse: TenantInvitation = {
|
||||
id: 12,
|
||||
email: 'partial@example.com',
|
||||
token: 'partial-token',
|
||||
status: 'PENDING',
|
||||
suggested_business_name: '',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
custom_max_users: null,
|
||||
custom_max_resources: null,
|
||||
permissions: {
|
||||
can_accept_payments: true,
|
||||
},
|
||||
personal_message: '',
|
||||
invited_by: 1,
|
||||
invited_by_email: 'admin@platform.com',
|
||||
created_at: '2025-01-15T16:00:00Z',
|
||||
expires_at: '2025-01-29T16:00:00Z',
|
||||
accepted_at: null,
|
||||
created_tenant: null,
|
||||
created_tenant_name: null,
|
||||
created_user: null,
|
||||
created_user_email: null,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createTenantInvitation(createData);
|
||||
|
||||
expect(result.permissions.can_accept_payments).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendTenantInvitation', () => {
|
||||
it('resends a tenant invitation by ID', async () => {
|
||||
const invitationId = 5;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await resendTenantInvitation(invitationId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/5/resend/'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles resend with no response data', async () => {
|
||||
const invitationId = 10;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
|
||||
|
||||
const result = await resendTenantInvitation(invitationId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/10/resend/'
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelTenantInvitation', () => {
|
||||
it('cancels a tenant invitation by ID', async () => {
|
||||
const invitationId = 7;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await cancelTenantInvitation(invitationId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/7/cancel/'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles cancellation with no response data', async () => {
|
||||
const invitationId = 15;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
|
||||
|
||||
const result = await cancelTenantInvitation(invitationId);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/15/cancel/'
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInvitationByToken', () => {
|
||||
it('fetches invitation details by token', async () => {
|
||||
const token = 'abc123token';
|
||||
const mockInvitation: TenantInvitationDetail = {
|
||||
email: 'invited@example.com',
|
||||
suggested_business_name: 'Invited Corp',
|
||||
subscription_tier: 'PROFESSIONAL',
|
||||
effective_max_users: 20,
|
||||
effective_max_resources: 50,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: true,
|
||||
},
|
||||
expires_at: '2025-01-30T12:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
|
||||
|
||||
const result = await getInvitationByToken(token);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/abc123token/'
|
||||
);
|
||||
expect(result).toEqual(mockInvitation);
|
||||
expect(result.email).toBe('invited@example.com');
|
||||
expect(result.effective_max_users).toBe(20);
|
||||
});
|
||||
|
||||
it('handles tokens with special characters', async () => {
|
||||
const token = 'token-with-dashes_and_underscores';
|
||||
const mockInvitation: TenantInvitationDetail = {
|
||||
email: 'test@example.com',
|
||||
suggested_business_name: 'Test',
|
||||
subscription_tier: 'FREE',
|
||||
effective_max_users: 3,
|
||||
effective_max_resources: 5,
|
||||
permissions: {},
|
||||
expires_at: '2025-02-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
|
||||
|
||||
await getInvitationByToken(token);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/token-with-dashes_and_underscores/'
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches invitation with custom limits', async () => {
|
||||
const token = 'custom-limits-token';
|
||||
const mockInvitation: TenantInvitationDetail = {
|
||||
email: 'custom@example.com',
|
||||
suggested_business_name: 'Custom Business',
|
||||
subscription_tier: 'ENTERPRISE',
|
||||
effective_max_users: 1000,
|
||||
effective_max_resources: 5000,
|
||||
permissions: {
|
||||
can_manage_oauth_credentials: true,
|
||||
can_accept_payments: true,
|
||||
can_use_custom_domain: true,
|
||||
can_white_label: true,
|
||||
can_api_access: true,
|
||||
},
|
||||
expires_at: '2025-03-01T12:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
|
||||
|
||||
const result = await getInvitationByToken(token);
|
||||
|
||||
expect(result.effective_max_users).toBe(1000);
|
||||
expect(result.effective_max_resources).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptInvitation', () => {
|
||||
it('accepts an invitation with full data', async () => {
|
||||
const token = 'accept-token-123';
|
||||
const acceptData: TenantInvitationAccept = {
|
||||
email: 'newowner@example.com',
|
||||
password: 'secure-password',
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
business_name: 'New Business LLC',
|
||||
subdomain: 'newbiz',
|
||||
contact_email: 'contact@newbiz.com',
|
||||
phone: '555-1234',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
detail: 'Invitation accepted successfully. Your account has been created.',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await acceptInvitation(token, acceptData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/accept-token-123/accept/',
|
||||
acceptData
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.detail).toContain('successfully');
|
||||
});
|
||||
|
||||
it('accepts an invitation with minimal data', async () => {
|
||||
const token = 'minimal-token';
|
||||
const acceptData: TenantInvitationAccept = {
|
||||
email: 'minimal@example.com',
|
||||
password: 'password123',
|
||||
first_name: 'Jane',
|
||||
last_name: 'Smith',
|
||||
business_name: 'Minimal Business',
|
||||
subdomain: 'minimal',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
detail: 'Account created successfully.',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await acceptInvitation(token, acceptData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/minimal-token/accept/',
|
||||
acceptData
|
||||
);
|
||||
expect(result.detail).toBe('Account created successfully.');
|
||||
});
|
||||
|
||||
it('handles acceptance with optional contact fields', async () => {
|
||||
const token = 'optional-fields-token';
|
||||
const acceptData: TenantInvitationAccept = {
|
||||
email: 'test@example.com',
|
||||
password: 'testpass',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
business_name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
contact_email: 'info@testbiz.com',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
detail: 'Welcome to the platform!',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await acceptInvitation(token, acceptData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/optional-fields-token/accept/',
|
||||
expect.objectContaining({
|
||||
contact_email: 'info@testbiz.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves all required fields in request', async () => {
|
||||
const token = 'complete-token';
|
||||
const acceptData: TenantInvitationAccept = {
|
||||
email: 'complete@example.com',
|
||||
password: 'strong-password-123',
|
||||
first_name: 'Complete',
|
||||
last_name: 'User',
|
||||
business_name: 'Complete Business Corp',
|
||||
subdomain: 'complete',
|
||||
contact_email: 'support@complete.com',
|
||||
phone: '555-9876',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({
|
||||
data: { detail: 'Success' },
|
||||
});
|
||||
|
||||
await acceptInvitation(token, acceptData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/platform/tenant-invitations/token/complete-token/accept/',
|
||||
expect.objectContaining({
|
||||
email: 'complete@example.com',
|
||||
password: 'strong-password-123',
|
||||
first_name: 'Complete',
|
||||
last_name: 'User',
|
||||
business_name: 'Complete Business Corp',
|
||||
subdomain: 'complete',
|
||||
contact_email: 'support@complete.com',
|
||||
phone: '555-9876',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
1232
frontend/src/api/__tests__/platformEmailAddresses.test.ts
Normal file
1232
frontend/src/api/__tests__/platformEmailAddresses.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1218
frontend/src/api/__tests__/platformOAuth.test.ts
Normal file
1218
frontend/src/api/__tests__/platformOAuth.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
335
frontend/src/api/__tests__/profile.test.ts
Normal file
335
frontend/src/api/__tests__/profile.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
deleteAvatar,
|
||||
sendVerificationEmail,
|
||||
verifyEmail,
|
||||
requestEmailChange,
|
||||
confirmEmailChange,
|
||||
changePassword,
|
||||
setupTOTP,
|
||||
verifyTOTP,
|
||||
disableTOTP,
|
||||
getRecoveryCodes,
|
||||
regenerateRecoveryCodes,
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeOtherSessions,
|
||||
getLoginHistory,
|
||||
sendPhoneVerification,
|
||||
verifyPhoneCode,
|
||||
getUserEmails,
|
||||
addUserEmail,
|
||||
deleteUserEmail,
|
||||
sendUserEmailVerification,
|
||||
verifyUserEmail,
|
||||
setPrimaryEmail,
|
||||
} from '../profile';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('profile API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('fetches user profile', async () => {
|
||||
const mockProfile = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
email_verified: true,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockProfile });
|
||||
|
||||
const result = await getProfile();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/profile/');
|
||||
expect(result).toEqual(mockProfile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfile', () => {
|
||||
it('updates profile with provided data', async () => {
|
||||
const mockUpdated = { id: 1, name: 'Updated Name' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockUpdated });
|
||||
|
||||
const result = await updateProfile({ name: 'Updated Name' });
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/auth/profile/', { name: 'Updated Name' });
|
||||
expect(result).toEqual(mockUpdated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAvatar', () => {
|
||||
it('uploads avatar file', async () => {
|
||||
const mockResponse = { avatar_url: 'https://example.com/avatar.jpg' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
const result = await uploadAvatar(file);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/auth/profile/avatar/',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
expect(result.avatar_url).toBe('https://example.com/avatar.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAvatar', () => {
|
||||
it('deletes user avatar', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await deleteAvatar();
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/profile/avatar/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('email verification', () => {
|
||||
it('sends verification email', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await sendVerificationEmail();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/send/');
|
||||
});
|
||||
|
||||
it('verifies email with token', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await verifyEmail('verification-token');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/confirm/', {
|
||||
token: 'verification-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('email change', () => {
|
||||
it('requests email change', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await requestEmailChange('new@example.com');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/', {
|
||||
new_email: 'new@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms email change', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await confirmEmailChange('change-token');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/confirm/', {
|
||||
token: 'change-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('changes password with current and new', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await changePassword('oldPassword', 'newPassword');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/password/change/', {
|
||||
current_password: 'oldPassword',
|
||||
new_password: 'newPassword',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('2FA / TOTP', () => {
|
||||
it('sets up TOTP', async () => {
|
||||
const mockSetup = {
|
||||
secret: 'ABCD1234',
|
||||
qr_code: 'base64...',
|
||||
provisioning_uri: 'otpauth://...',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetup });
|
||||
|
||||
const result = await setupTOTP();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
|
||||
expect(result.secret).toBe('ABCD1234');
|
||||
});
|
||||
|
||||
it('verifies TOTP code', async () => {
|
||||
const mockResponse = { success: true, backup_codes: ['code1', 'code2'] };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await verifyTOTP('123456');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', { code: '123456' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.recovery_codes).toEqual(['code1', 'code2']);
|
||||
});
|
||||
|
||||
it('disables TOTP', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await disableTOTP('123456');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', { mfa_code: '123456' });
|
||||
});
|
||||
|
||||
it('gets recovery codes status', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await getRecoveryCodes();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('regenerates recovery codes', async () => {
|
||||
const mockCodes = ['code1', 'code2', 'code3'];
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { backup_codes: mockCodes } });
|
||||
|
||||
const result = await regenerateRecoveryCodes();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
|
||||
expect(result).toEqual(mockCodes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
it('gets sessions', async () => {
|
||||
const mockSessions = [
|
||||
{ id: '1', device_info: 'Chrome', ip_address: '1.1.1.1', is_current: true },
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSessions });
|
||||
|
||||
const result = await getSessions();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/sessions/');
|
||||
expect(result).toEqual(mockSessions);
|
||||
});
|
||||
|
||||
it('revokes session', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await revokeSession('session-123');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/sessions/session-123/');
|
||||
});
|
||||
|
||||
it('revokes other sessions', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await revokeOtherSessions();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/sessions/revoke-others/');
|
||||
});
|
||||
|
||||
it('gets login history', async () => {
|
||||
const mockHistory = [
|
||||
{ id: '1', timestamp: '2024-01-01', success: true },
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
|
||||
|
||||
const result = await getLoginHistory();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/login-history/');
|
||||
expect(result).toEqual(mockHistory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone verification', () => {
|
||||
it('sends phone verification', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await sendPhoneVerification('555-1234');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/send/', {
|
||||
phone: '555-1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies phone code', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await verifyPhoneCode('123456');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/confirm/', {
|
||||
code: '123456',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple emails', () => {
|
||||
it('gets user emails', async () => {
|
||||
const mockEmails = [
|
||||
{ id: 1, email: 'primary@example.com', is_primary: true, verified: true },
|
||||
{ id: 2, email: 'secondary@example.com', is_primary: false, verified: false },
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
|
||||
|
||||
const result = await getUserEmails();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/auth/emails/');
|
||||
expect(result).toEqual(mockEmails);
|
||||
});
|
||||
|
||||
it('adds user email', async () => {
|
||||
const mockEmail = { id: 3, email: 'new@example.com', is_primary: false, verified: false };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockEmail });
|
||||
|
||||
const result = await addUserEmail('new@example.com');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/', { email: 'new@example.com' });
|
||||
expect(result).toEqual(mockEmail);
|
||||
});
|
||||
|
||||
it('deletes user email', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await deleteUserEmail(2);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/auth/emails/2/');
|
||||
});
|
||||
|
||||
it('sends user email verification', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await sendUserEmailVerification(2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/send-verification/');
|
||||
});
|
||||
|
||||
it('verifies user email', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await verifyUserEmail(2, 'verify-token');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/verify/', {
|
||||
token: 'verify-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets primary email', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({});
|
||||
|
||||
await setPrimaryEmail(2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/set-primary/');
|
||||
});
|
||||
});
|
||||
});
|
||||
609
frontend/src/api/__tests__/quota.test.ts
Normal file
609
frontend/src/api/__tests__/quota.test.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getQuotaStatus,
|
||||
getQuotaResources,
|
||||
archiveResources,
|
||||
unarchiveResource,
|
||||
getOverageDetail,
|
||||
QuotaStatus,
|
||||
QuotaResourcesResponse,
|
||||
ArchiveResponse,
|
||||
QuotaOverageDetail,
|
||||
} from '../quota';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('quota API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getQuotaStatus', () => {
|
||||
it('fetches quota status from API', async () => {
|
||||
const mockQuotaStatus: QuotaStatus = {
|
||||
active_overages: [
|
||||
{
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14T00:00:00Z',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
resources: {
|
||||
current: 15,
|
||||
limit: 10,
|
||||
display_name: 'Resources',
|
||||
},
|
||||
staff: {
|
||||
current: 3,
|
||||
limit: 5,
|
||||
display_name: 'Staff Members',
|
||||
},
|
||||
services: {
|
||||
current: 8,
|
||||
limit: 20,
|
||||
display_name: 'Services',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
|
||||
|
||||
const result = await getQuotaStatus();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
|
||||
expect(result).toEqual(mockQuotaStatus);
|
||||
expect(result.active_overages).toHaveLength(1);
|
||||
expect(result.usage.resources.current).toBe(15);
|
||||
});
|
||||
|
||||
it('returns empty active_overages when no overages exist', async () => {
|
||||
const mockQuotaStatus: QuotaStatus = {
|
||||
active_overages: [],
|
||||
usage: {
|
||||
resources: {
|
||||
current: 5,
|
||||
limit: 10,
|
||||
display_name: 'Resources',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
|
||||
|
||||
const result = await getQuotaStatus();
|
||||
|
||||
expect(result.active_overages).toHaveLength(0);
|
||||
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
|
||||
});
|
||||
|
||||
it('handles multiple quota types in usage', async () => {
|
||||
const mockQuotaStatus: QuotaStatus = {
|
||||
active_overages: [],
|
||||
usage: {
|
||||
resources: {
|
||||
current: 5,
|
||||
limit: 10,
|
||||
display_name: 'Resources',
|
||||
},
|
||||
staff: {
|
||||
current: 2,
|
||||
limit: 5,
|
||||
display_name: 'Staff Members',
|
||||
},
|
||||
services: {
|
||||
current: 15,
|
||||
limit: 20,
|
||||
display_name: 'Services',
|
||||
},
|
||||
customers: {
|
||||
current: 100,
|
||||
limit: 500,
|
||||
display_name: 'Customers',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
|
||||
|
||||
const result = await getQuotaStatus();
|
||||
|
||||
expect(Object.keys(result.usage)).toHaveLength(4);
|
||||
expect(result.usage).toHaveProperty('resources');
|
||||
expect(result.usage).toHaveProperty('staff');
|
||||
expect(result.usage).toHaveProperty('services');
|
||||
expect(result.usage).toHaveProperty('customers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQuotaResources', () => {
|
||||
it('fetches resources for a specific quota type', async () => {
|
||||
const mockResourcesResponse: QuotaResourcesResponse = {
|
||||
quota_type: 'resources',
|
||||
resources: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Conference Room A',
|
||||
type: 'room',
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Conference Room B',
|
||||
type: 'room',
|
||||
created_at: '2025-01-02T11:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
|
||||
|
||||
const result = await getQuotaResources('resources');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
|
||||
expect(result).toEqual(mockResourcesResponse);
|
||||
expect(result.quota_type).toBe('resources');
|
||||
expect(result.resources).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fetches staff members for staff quota type', async () => {
|
||||
const mockStaffResponse: QuotaResourcesResponse = {
|
||||
quota_type: 'staff',
|
||||
resources: [
|
||||
{
|
||||
id: 10,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'staff',
|
||||
created_at: '2025-01-15T09:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
role: 'manager',
|
||||
created_at: '2025-01-16T09:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
|
||||
|
||||
const result = await getQuotaResources('staff');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
|
||||
expect(result.quota_type).toBe('staff');
|
||||
expect(result.resources[0]).toHaveProperty('email');
|
||||
expect(result.resources[0]).toHaveProperty('role');
|
||||
});
|
||||
|
||||
it('fetches services for services quota type', async () => {
|
||||
const mockServicesResponse: QuotaResourcesResponse = {
|
||||
quota_type: 'services',
|
||||
resources: [
|
||||
{
|
||||
id: 20,
|
||||
name: 'Haircut',
|
||||
duration: 30,
|
||||
price: '25.00',
|
||||
created_at: '2025-02-01T10:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
name: 'Color Treatment',
|
||||
duration: 90,
|
||||
price: '75.00',
|
||||
created_at: '2025-02-02T10:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
|
||||
|
||||
const result = await getQuotaResources('services');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
|
||||
expect(result.quota_type).toBe('services');
|
||||
expect(result.resources[0]).toHaveProperty('duration');
|
||||
expect(result.resources[0]).toHaveProperty('price');
|
||||
});
|
||||
|
||||
it('includes archived resources', async () => {
|
||||
const mockResourcesResponse: QuotaResourcesResponse = {
|
||||
quota_type: 'resources',
|
||||
resources: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Active Resource',
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
is_archived: false,
|
||||
archived_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Archived Resource',
|
||||
created_at: '2024-12-01T10:00:00Z',
|
||||
is_archived: true,
|
||||
archived_at: '2025-12-01T15:30:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
|
||||
|
||||
const result = await getQuotaResources('resources');
|
||||
|
||||
expect(result.resources).toHaveLength(2);
|
||||
expect(result.resources[0].is_archived).toBe(false);
|
||||
expect(result.resources[1].is_archived).toBe(true);
|
||||
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
|
||||
});
|
||||
|
||||
it('handles empty resources list', async () => {
|
||||
const mockEmptyResponse: QuotaResourcesResponse = {
|
||||
quota_type: 'resources',
|
||||
resources: [],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
|
||||
|
||||
const result = await getQuotaResources('resources');
|
||||
|
||||
expect(result.resources).toHaveLength(0);
|
||||
expect(result.quota_type).toBe('resources');
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveResources', () => {
|
||||
it('archives multiple resources successfully', async () => {
|
||||
const mockArchiveResponse: ArchiveResponse = {
|
||||
archived_count: 3,
|
||||
current_usage: 7,
|
||||
limit: 10,
|
||||
is_resolved: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
|
||||
|
||||
const result = await archiveResources('resources', [1, 2, 3]);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
|
||||
quota_type: 'resources',
|
||||
resource_ids: [1, 2, 3],
|
||||
});
|
||||
expect(result).toEqual(mockArchiveResponse);
|
||||
expect(result.archived_count).toBe(3);
|
||||
expect(result.is_resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('archives single resource', async () => {
|
||||
const mockArchiveResponse: ArchiveResponse = {
|
||||
archived_count: 1,
|
||||
current_usage: 9,
|
||||
limit: 10,
|
||||
is_resolved: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
|
||||
|
||||
const result = await archiveResources('staff', [5]);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
|
||||
quota_type: 'staff',
|
||||
resource_ids: [5],
|
||||
});
|
||||
expect(result.archived_count).toBe(1);
|
||||
});
|
||||
|
||||
it('indicates overage is still not resolved after archiving', async () => {
|
||||
const mockArchiveResponse: ArchiveResponse = {
|
||||
archived_count: 2,
|
||||
current_usage: 12,
|
||||
limit: 10,
|
||||
is_resolved: false,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
|
||||
|
||||
const result = await archiveResources('resources', [1, 2]);
|
||||
|
||||
expect(result.is_resolved).toBe(false);
|
||||
expect(result.current_usage).toBeGreaterThan(result.limit);
|
||||
});
|
||||
|
||||
it('handles archiving with different quota types', async () => {
|
||||
const mockArchiveResponse: ArchiveResponse = {
|
||||
archived_count: 5,
|
||||
current_usage: 15,
|
||||
limit: 20,
|
||||
is_resolved: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
|
||||
|
||||
await archiveResources('services', [10, 11, 12, 13, 14]);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
|
||||
quota_type: 'services',
|
||||
resource_ids: [10, 11, 12, 13, 14],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty resource_ids array', async () => {
|
||||
const mockArchiveResponse: ArchiveResponse = {
|
||||
archived_count: 0,
|
||||
current_usage: 10,
|
||||
limit: 10,
|
||||
is_resolved: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
|
||||
|
||||
const result = await archiveResources('resources', []);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
|
||||
quota_type: 'resources',
|
||||
resource_ids: [],
|
||||
});
|
||||
expect(result.archived_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveResource', () => {
|
||||
it('unarchives a resource successfully', async () => {
|
||||
const mockUnarchiveResponse = {
|
||||
success: true,
|
||||
resource_id: 5,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
|
||||
|
||||
const result = await unarchiveResource('resources', 5);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
|
||||
quota_type: 'resources',
|
||||
resource_id: 5,
|
||||
});
|
||||
expect(result).toEqual(mockUnarchiveResponse);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resource_id).toBe(5);
|
||||
});
|
||||
|
||||
it('unarchives staff member', async () => {
|
||||
const mockUnarchiveResponse = {
|
||||
success: true,
|
||||
resource_id: 10,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
|
||||
|
||||
const result = await unarchiveResource('staff', 10);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
|
||||
quota_type: 'staff',
|
||||
resource_id: 10,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('unarchives service', async () => {
|
||||
const mockUnarchiveResponse = {
|
||||
success: true,
|
||||
resource_id: 20,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
|
||||
|
||||
const result = await unarchiveResource('services', 20);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
|
||||
quota_type: 'services',
|
||||
resource_id: 20,
|
||||
});
|
||||
expect(result.resource_id).toBe(20);
|
||||
});
|
||||
|
||||
it('handles unsuccessful unarchive', async () => {
|
||||
const mockUnarchiveResponse = {
|
||||
success: false,
|
||||
resource_id: 5,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
|
||||
|
||||
const result = await unarchiveResource('resources', 5);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOverageDetail', () => {
|
||||
it('fetches detailed overage information', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-14T00:00:00Z',
|
||||
status: 'active',
|
||||
created_at: '2025-12-07T10:00:00Z',
|
||||
initial_email_sent_at: '2025-12-07T10:05:00Z',
|
||||
week_reminder_sent_at: null,
|
||||
day_reminder_sent_at: null,
|
||||
archived_resource_ids: [],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
|
||||
expect(result).toEqual(mockOverageDetail);
|
||||
expect(result.status).toBe('active');
|
||||
expect(result.overage_amount).toBe(5);
|
||||
});
|
||||
|
||||
it('includes sent email timestamps', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 2,
|
||||
quota_type: 'staff',
|
||||
display_name: 'Staff Members',
|
||||
current_usage: 8,
|
||||
allowed_limit: 5,
|
||||
overage_amount: 3,
|
||||
days_remaining: 3,
|
||||
grace_period_ends_at: '2025-12-10T00:00:00Z',
|
||||
status: 'active',
|
||||
created_at: '2025-11-30T10:00:00Z',
|
||||
initial_email_sent_at: '2025-11-30T10:05:00Z',
|
||||
week_reminder_sent_at: '2025-12-03T09:00:00Z',
|
||||
day_reminder_sent_at: '2025-12-06T09:00:00Z',
|
||||
archived_resource_ids: [],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(2);
|
||||
|
||||
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
|
||||
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
|
||||
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
|
||||
});
|
||||
|
||||
it('includes archived resource IDs', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 3,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 10,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 0,
|
||||
days_remaining: 5,
|
||||
grace_period_ends_at: '2025-12-12T00:00:00Z',
|
||||
status: 'resolved',
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
initial_email_sent_at: '2025-12-01T10:05:00Z',
|
||||
week_reminder_sent_at: null,
|
||||
day_reminder_sent_at: null,
|
||||
archived_resource_ids: [1, 3, 5, 7],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(3);
|
||||
|
||||
expect(result.archived_resource_ids).toHaveLength(4);
|
||||
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
|
||||
expect(result.status).toBe('resolved');
|
||||
});
|
||||
|
||||
it('handles resolved overage with zero overage_amount', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 4,
|
||||
quota_type: 'services',
|
||||
display_name: 'Services',
|
||||
current_usage: 18,
|
||||
allowed_limit: 20,
|
||||
overage_amount: 0,
|
||||
days_remaining: 0,
|
||||
grace_period_ends_at: '2025-12-05T00:00:00Z',
|
||||
status: 'resolved',
|
||||
created_at: '2025-11-25T10:00:00Z',
|
||||
initial_email_sent_at: '2025-11-25T10:05:00Z',
|
||||
week_reminder_sent_at: '2025-11-28T09:00:00Z',
|
||||
day_reminder_sent_at: null,
|
||||
archived_resource_ids: [20, 21],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(4);
|
||||
|
||||
expect(result.overage_amount).toBe(0);
|
||||
expect(result.status).toBe('resolved');
|
||||
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
|
||||
});
|
||||
|
||||
it('handles expired overage', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 5,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 0,
|
||||
grace_period_ends_at: '2025-12-06T00:00:00Z',
|
||||
status: 'expired',
|
||||
created_at: '2025-11-20T10:00:00Z',
|
||||
initial_email_sent_at: '2025-11-20T10:05:00Z',
|
||||
week_reminder_sent_at: '2025-11-27T09:00:00Z',
|
||||
day_reminder_sent_at: '2025-12-05T09:00:00Z',
|
||||
archived_resource_ids: [],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(5);
|
||||
|
||||
expect(result.status).toBe('expired');
|
||||
expect(result.days_remaining).toBe(0);
|
||||
expect(result.overage_amount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles null email timestamps when no reminders sent', async () => {
|
||||
const mockOverageDetail: QuotaOverageDetail = {
|
||||
id: 6,
|
||||
quota_type: 'staff',
|
||||
display_name: 'Staff Members',
|
||||
current_usage: 6,
|
||||
allowed_limit: 5,
|
||||
overage_amount: 1,
|
||||
days_remaining: 14,
|
||||
grace_period_ends_at: '2025-12-21T00:00:00Z',
|
||||
status: 'active',
|
||||
created_at: '2025-12-07T10:00:00Z',
|
||||
initial_email_sent_at: null,
|
||||
week_reminder_sent_at: null,
|
||||
day_reminder_sent_at: null,
|
||||
archived_resource_ids: [],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
|
||||
|
||||
const result = await getOverageDetail(6);
|
||||
|
||||
expect(result.initial_email_sent_at).toBeNull();
|
||||
expect(result.week_reminder_sent_at).toBeNull();
|
||||
expect(result.day_reminder_sent_at).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
208
frontend/src/api/__tests__/sandbox.test.ts
Normal file
208
frontend/src/api/__tests__/sandbox.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getSandboxStatus,
|
||||
toggleSandboxMode,
|
||||
resetSandboxData,
|
||||
} from '../sandbox';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('sandbox API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getSandboxStatus', () => {
|
||||
it('fetches sandbox status from API', async () => {
|
||||
const mockStatus = {
|
||||
sandbox_mode: true,
|
||||
sandbox_enabled: true,
|
||||
sandbox_schema: 'test_business_sandbox',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getSandboxStatus();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/sandbox/status/');
|
||||
expect(result).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it('returns sandbox disabled status', async () => {
|
||||
const mockStatus = {
|
||||
sandbox_mode: false,
|
||||
sandbox_enabled: false,
|
||||
sandbox_schema: null,
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getSandboxStatus();
|
||||
|
||||
expect(result.sandbox_mode).toBe(false);
|
||||
expect(result.sandbox_enabled).toBe(false);
|
||||
expect(result.sandbox_schema).toBeNull();
|
||||
});
|
||||
|
||||
it('returns sandbox enabled but not active', async () => {
|
||||
const mockStatus = {
|
||||
sandbox_mode: false,
|
||||
sandbox_enabled: true,
|
||||
sandbox_schema: 'test_business_sandbox',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getSandboxStatus();
|
||||
|
||||
expect(result.sandbox_mode).toBe(false);
|
||||
expect(result.sandbox_enabled).toBe(true);
|
||||
expect(result.sandbox_schema).toBe('test_business_sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSandboxMode', () => {
|
||||
it('enables sandbox mode', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
sandbox_mode: true,
|
||||
message: 'Sandbox mode enabled',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await toggleSandboxMode(true);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
|
||||
sandbox: true,
|
||||
});
|
||||
expect(result.sandbox_mode).toBe(true);
|
||||
expect(result.message).toBe('Sandbox mode enabled');
|
||||
});
|
||||
|
||||
it('disables sandbox mode', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
sandbox_mode: false,
|
||||
message: 'Sandbox mode disabled',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await toggleSandboxMode(false);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
|
||||
sandbox: false,
|
||||
});
|
||||
expect(result.sandbox_mode).toBe(false);
|
||||
expect(result.message).toBe('Sandbox mode disabled');
|
||||
});
|
||||
|
||||
it('handles toggle with true parameter', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
sandbox_mode: true,
|
||||
message: 'Switched to test data',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await toggleSandboxMode(true);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
|
||||
sandbox: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles toggle with false parameter', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
sandbox_mode: false,
|
||||
message: 'Switched to live data',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await toggleSandboxMode(false);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
|
||||
sandbox: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetSandboxData', () => {
|
||||
it('resets sandbox data successfully', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
message: 'Sandbox data reset successfully',
|
||||
sandbox_schema: 'test_business_sandbox',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await resetSandboxData();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
|
||||
expect(result.message).toBe('Sandbox data reset successfully');
|
||||
expect(result.sandbox_schema).toBe('test_business_sandbox');
|
||||
});
|
||||
|
||||
it('returns schema name after reset', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
message: 'Data reset complete',
|
||||
sandbox_schema: 'my_company_sandbox',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await resetSandboxData();
|
||||
|
||||
expect(result.sandbox_schema).toBe('my_company_sandbox');
|
||||
});
|
||||
|
||||
it('calls reset endpoint without parameters', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
message: 'Reset successful',
|
||||
sandbox_schema: 'test_sandbox',
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
|
||||
|
||||
await resetSandboxData();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('propagates errors from getSandboxStatus', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(error);
|
||||
|
||||
await expect(getSandboxStatus()).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('propagates errors from toggleSandboxMode', async () => {
|
||||
const error = new Error('Unauthorized');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(error);
|
||||
|
||||
await expect(toggleSandboxMode(true)).rejects.toThrow('Unauthorized');
|
||||
});
|
||||
|
||||
it('propagates errors from resetSandboxData', async () => {
|
||||
const error = new Error('Forbidden');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(error);
|
||||
|
||||
await expect(resetSandboxData()).rejects.toThrow('Forbidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
793
frontend/src/api/__tests__/ticketEmailAddresses.test.ts
Normal file
793
frontend/src/api/__tests__/ticketEmailAddresses.test.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getTicketEmailAddresses,
|
||||
getTicketEmailAddress,
|
||||
createTicketEmailAddress,
|
||||
updateTicketEmailAddress,
|
||||
deleteTicketEmailAddress,
|
||||
testImapConnection,
|
||||
testSmtpConnection,
|
||||
fetchEmailsNow,
|
||||
setAsDefault,
|
||||
TicketEmailAddressListItem,
|
||||
TicketEmailAddress,
|
||||
TicketEmailAddressCreate,
|
||||
TestConnectionResponse,
|
||||
FetchEmailsResponse,
|
||||
} from '../ticketEmailAddresses';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('ticketEmailAddresses API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getTicketEmailAddresses', () => {
|
||||
it('should fetch all ticket email addresses', async () => {
|
||||
const mockAddresses: TicketEmailAddressListItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
last_check_at: '2025-12-07T10:00:00Z',
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
display_name: 'Sales',
|
||||
email_address: 'sales@example.com',
|
||||
color: '#3357FF',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
emails_processed_count: 15,
|
||||
created_at: '2025-12-02T10:00:00Z',
|
||||
updated_at: '2025-12-05T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddresses });
|
||||
|
||||
const result = await getTicketEmailAddresses();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
|
||||
expect(result).toEqual(mockAddresses);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty array when no addresses exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getTicketEmailAddresses();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw error when API call fails', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(mockError);
|
||||
|
||||
await expect(getTicketEmailAddresses()).rejects.toThrow('Network error');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTicketEmailAddress', () => {
|
||||
it('should fetch a specific ticket email address by ID', async () => {
|
||||
const mockAddress: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
last_check_at: '2025-12-07T10:00:00Z',
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
|
||||
|
||||
const result = await getTicketEmailAddress(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/1/');
|
||||
expect(result).toEqual(mockAddress);
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.email_address).toBe('support@example.com');
|
||||
});
|
||||
|
||||
it('should handle fetching with different IDs', async () => {
|
||||
const mockAddress: TicketEmailAddress = {
|
||||
id: 999,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Sales',
|
||||
email_address: 'sales@example.com',
|
||||
color: '#3357FF',
|
||||
imap_host: 'imap.example.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'sales@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.example.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'sales@example.com',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
emails_processed_count: 0,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-01T10:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
|
||||
|
||||
const result = await getTicketEmailAddress(999);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
|
||||
expect(result.id).toBe(999);
|
||||
});
|
||||
|
||||
it('should throw error when address not found', async () => {
|
||||
const mockError = new Error('Not found');
|
||||
vi.mocked(apiClient.get).mockRejectedValue(mockError);
|
||||
|
||||
await expect(getTicketEmailAddress(999)).rejects.toThrow('Not found');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTicketEmailAddress', () => {
|
||||
it('should create a new ticket email address', async () => {
|
||||
const createData: TicketEmailAddressCreate = {
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_password: 'secure_password',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
smtp_password: 'secure_password',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
...createData,
|
||||
imap_password: undefined, // Passwords are not returned in response
|
||||
smtp_password: undefined,
|
||||
last_check_at: undefined,
|
||||
last_error: undefined,
|
||||
emails_processed_count: 0,
|
||||
created_at: '2025-12-07T10:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createTicketEmailAddress(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.display_name).toBe('Support');
|
||||
});
|
||||
|
||||
it('should handle creating with minimal required fields', async () => {
|
||||
const createData: TicketEmailAddressCreate = {
|
||||
display_name: 'Minimal',
|
||||
email_address: 'minimal@example.com',
|
||||
color: '#000000',
|
||||
imap_host: 'imap.example.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'minimal@example.com',
|
||||
imap_password: 'password',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.example.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: false,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'minimal@example.com',
|
||||
smtp_password: 'password',
|
||||
is_active: false,
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 2,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
...createData,
|
||||
imap_password: undefined,
|
||||
smtp_password: undefined,
|
||||
emails_processed_count: 0,
|
||||
created_at: '2025-12-07T10:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await createTicketEmailAddress(createData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
|
||||
expect(result.id).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error when validation fails', async () => {
|
||||
const invalidData: TicketEmailAddressCreate = {
|
||||
display_name: '',
|
||||
email_address: 'invalid-email',
|
||||
color: '#FF5733',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: '',
|
||||
imap_password: '',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
is_active: true,
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
const mockError = new Error('Validation error');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(createTicketEmailAddress(invalidData)).rejects.toThrow('Validation error');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', invalidData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTicketEmailAddress', () => {
|
||||
it('should update an existing ticket email address', async () => {
|
||||
const updateData: Partial<TicketEmailAddressCreate> = {
|
||||
display_name: 'Updated Support',
|
||||
color: '#00FF00',
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Updated Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#00FF00',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T11:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailAddress(1, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.display_name).toBe('Updated Support');
|
||||
expect(result.color).toBe('#00FF00');
|
||||
});
|
||||
|
||||
it('should update IMAP configuration', async () => {
|
||||
const updateData: Partial<TicketEmailAddressCreate> = {
|
||||
imap_host: 'imap.newserver.com',
|
||||
imap_port: 993,
|
||||
imap_password: 'new_password',
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
imap_host: 'imap.newserver.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T11:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailAddress(1, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
|
||||
expect(result.imap_host).toBe('imap.newserver.com');
|
||||
});
|
||||
|
||||
it('should update SMTP configuration', async () => {
|
||||
const updateData: Partial<TicketEmailAddressCreate> = {
|
||||
smtp_host: 'smtp.newserver.com',
|
||||
smtp_port: 465,
|
||||
smtp_use_tls: false,
|
||||
smtp_use_ssl: true,
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.newserver.com',
|
||||
smtp_port: 465,
|
||||
smtp_use_tls: false,
|
||||
smtp_use_ssl: true,
|
||||
smtp_username: 'support@example.com',
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T11:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailAddress(1, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
|
||||
expect(result.smtp_host).toBe('smtp.newserver.com');
|
||||
expect(result.smtp_port).toBe(465);
|
||||
expect(result.smtp_use_ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle is_active status', async () => {
|
||||
const updateData: Partial<TicketEmailAddressCreate> = {
|
||||
is_active: false,
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailAddress = {
|
||||
id: 1,
|
||||
tenant: 100,
|
||||
tenant_name: 'Test Business',
|
||||
display_name: 'Support',
|
||||
email_address: 'support@example.com',
|
||||
color: '#FF5733',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
is_active: false,
|
||||
is_default: true,
|
||||
emails_processed_count: 42,
|
||||
created_at: '2025-12-01T10:00:00Z',
|
||||
updated_at: '2025-12-07T11:00:00Z',
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
is_fully_configured: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailAddress(1, updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
|
||||
expect(result.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error when update fails', async () => {
|
||||
const updateData: Partial<TicketEmailAddressCreate> = {
|
||||
display_name: 'Invalid',
|
||||
};
|
||||
|
||||
const mockError = new Error('Update failed');
|
||||
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
|
||||
|
||||
await expect(updateTicketEmailAddress(1, updateData)).rejects.toThrow('Update failed');
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTicketEmailAddress', () => {
|
||||
it('should delete a ticket email address', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteTicketEmailAddress(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
|
||||
});
|
||||
|
||||
it('should handle deletion of different IDs', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteTicketEmailAddress(999);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
|
||||
});
|
||||
|
||||
it('should throw error when deletion fails', async () => {
|
||||
const mockError = new Error('Cannot delete default address');
|
||||
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
|
||||
|
||||
await expect(deleteTicketEmailAddress(1)).rejects.toThrow('Cannot delete default address');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
|
||||
});
|
||||
|
||||
it('should throw error when address not found', async () => {
|
||||
const mockError = new Error('Not found');
|
||||
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
|
||||
|
||||
await expect(deleteTicketEmailAddress(999)).rejects.toThrow('Not found');
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testImapConnection', () => {
|
||||
it('should test IMAP connection successfully', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: true,
|
||||
message: 'IMAP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await testImapConnection(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('IMAP connection successful');
|
||||
});
|
||||
|
||||
it('should handle failed IMAP connection', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: false,
|
||||
message: 'Authentication failed: Invalid credentials',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await testImapConnection(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Invalid credentials');
|
||||
});
|
||||
|
||||
it('should handle network errors during IMAP test', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(testImapConnection(1)).rejects.toThrow('Network error');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
|
||||
});
|
||||
|
||||
it('should test IMAP connection for different addresses', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: true,
|
||||
message: 'IMAP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await testImapConnection(42);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/test_imap/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSmtpConnection', () => {
|
||||
it('should test SMTP connection successfully', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: true,
|
||||
message: 'SMTP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await testSmtpConnection(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('SMTP connection successful');
|
||||
});
|
||||
|
||||
it('should handle failed SMTP connection', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: false,
|
||||
message: 'Connection refused: Unable to connect to SMTP server',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await testSmtpConnection(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('should handle TLS/SSL errors during SMTP test', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: false,
|
||||
message: 'SSL certificate verification failed',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await testSmtpConnection(1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('SSL certificate');
|
||||
});
|
||||
|
||||
it('should handle network errors during SMTP test', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(testSmtpConnection(1)).rejects.toThrow('Network error');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
|
||||
});
|
||||
|
||||
it('should test SMTP connection for different addresses', async () => {
|
||||
const mockResponse: TestConnectionResponse = {
|
||||
success: true,
|
||||
message: 'SMTP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await testSmtpConnection(99);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/test_smtp/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEmailsNow', () => {
|
||||
it('should fetch emails successfully', async () => {
|
||||
const mockResponse: FetchEmailsResponse = {
|
||||
success: true,
|
||||
message: 'Successfully processed 5 emails',
|
||||
processed: 5,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await fetchEmailsNow(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processed).toBe(5);
|
||||
expect(result.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle fetching with no new emails', async () => {
|
||||
const mockResponse: FetchEmailsResponse = {
|
||||
success: true,
|
||||
message: 'No new emails to process',
|
||||
processed: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await fetchEmailsNow(1);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processed).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle errors during email processing', async () => {
|
||||
const mockResponse: FetchEmailsResponse = {
|
||||
success: false,
|
||||
message: 'Failed to connect to IMAP server',
|
||||
processed: 0,
|
||||
errors: 1,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await fetchEmailsNow(1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBe(1);
|
||||
expect(result.message).toContain('Failed to connect');
|
||||
});
|
||||
|
||||
it('should handle partial processing with errors', async () => {
|
||||
const mockResponse: FetchEmailsResponse = {
|
||||
success: true,
|
||||
message: 'Processed 8 emails with 2 errors',
|
||||
processed: 8,
|
||||
errors: 2,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await fetchEmailsNow(1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processed).toBe(8);
|
||||
expect(result.errors).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle network errors during fetch', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(fetchEmailsNow(1)).rejects.toThrow('Network error');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
|
||||
});
|
||||
|
||||
it('should fetch emails for different addresses', async () => {
|
||||
const mockResponse: FetchEmailsResponse = {
|
||||
success: true,
|
||||
message: 'Successfully processed 3 emails',
|
||||
processed: 3,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await fetchEmailsNow(42);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/fetch_now/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAsDefault', () => {
|
||||
it('should set email address as default successfully', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Email address set as default',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await setAsDefault(2);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/2/set_as_default/');
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Email address set as default');
|
||||
});
|
||||
|
||||
it('should handle setting default for different addresses', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Email address set as default',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await setAsDefault(99);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/set_as_default/');
|
||||
});
|
||||
|
||||
it('should handle failure to set as default', async () => {
|
||||
const mockResponse = {
|
||||
success: false,
|
||||
message: 'Cannot set inactive email as default',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await setAsDefault(1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Cannot set inactive');
|
||||
});
|
||||
|
||||
it('should handle network errors when setting default', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(setAsDefault(1)).rejects.toThrow('Network error');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/set_as_default/');
|
||||
});
|
||||
|
||||
it('should handle not found errors', async () => {
|
||||
const mockError = new Error('Email address not found');
|
||||
vi.mocked(apiClient.post).mockRejectedValue(mockError);
|
||||
|
||||
await expect(setAsDefault(999)).rejects.toThrow('Email address not found');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/999/set_as_default/');
|
||||
});
|
||||
});
|
||||
});
|
||||
703
frontend/src/api/__tests__/ticketEmailSettings.test.ts
Normal file
703
frontend/src/api/__tests__/ticketEmailSettings.test.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getTicketEmailSettings,
|
||||
updateTicketEmailSettings,
|
||||
testImapConnection,
|
||||
testSmtpConnection,
|
||||
testEmailConnection,
|
||||
fetchEmailsNow,
|
||||
getIncomingEmails,
|
||||
reprocessIncomingEmail,
|
||||
detectEmailProvider,
|
||||
getOAuthStatus,
|
||||
initiateGoogleOAuth,
|
||||
initiateMicrosoftOAuth,
|
||||
getOAuthCredentials,
|
||||
deleteOAuthCredential,
|
||||
type TicketEmailSettings,
|
||||
type TicketEmailSettingsUpdate,
|
||||
type TestConnectionResult,
|
||||
type FetchNowResult,
|
||||
type IncomingTicketEmail,
|
||||
type EmailProviderDetectResult,
|
||||
type OAuthStatusResult,
|
||||
type OAuthInitiateResult,
|
||||
type OAuthCredential,
|
||||
} from '../ticketEmailSettings';
|
||||
import apiClient from '../client';
|
||||
|
||||
describe('ticketEmailSettings API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getTicketEmailSettings', () => {
|
||||
it('should call GET /tickets/email-settings/', async () => {
|
||||
const mockSettings: TicketEmailSettings = {
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_password_masked: '***',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
smtp_password_masked: '***',
|
||||
smtp_from_email: 'support@example.com',
|
||||
smtp_from_name: 'Support Team',
|
||||
support_email_address: 'support@example.com',
|
||||
support_email_domain: 'example.com',
|
||||
is_enabled: true,
|
||||
delete_after_processing: false,
|
||||
check_interval_seconds: 300,
|
||||
max_attachment_size_mb: 10,
|
||||
allowed_attachment_types: ['pdf', 'jpg', 'png'],
|
||||
last_check_at: '2025-12-07T10:00:00Z',
|
||||
last_error: '',
|
||||
emails_processed_count: 42,
|
||||
is_configured: true,
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
|
||||
|
||||
const result = await getTicketEmailSettings();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-settings/');
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTicketEmailSettings', () => {
|
||||
it('should call PATCH /tickets/email-settings/ with update data', async () => {
|
||||
const updateData: TicketEmailSettingsUpdate = {
|
||||
imap_host: 'imap.outlook.com',
|
||||
imap_port: 993,
|
||||
is_enabled: true,
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailSettings = {
|
||||
imap_host: 'imap.outlook.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_password_masked: '***',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.outlook.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
smtp_password_masked: '***',
|
||||
smtp_from_email: 'support@example.com',
|
||||
smtp_from_name: 'Support Team',
|
||||
support_email_address: 'support@example.com',
|
||||
support_email_domain: 'example.com',
|
||||
is_enabled: true,
|
||||
delete_after_processing: false,
|
||||
check_interval_seconds: 300,
|
||||
max_attachment_size_mb: 10,
|
||||
allowed_attachment_types: ['pdf', 'jpg', 'png'],
|
||||
last_check_at: null,
|
||||
last_error: '',
|
||||
emails_processed_count: 0,
|
||||
is_configured: true,
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailSettings(updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
|
||||
expect(apiClient.patch).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle password updates', async () => {
|
||||
const updateData: TicketEmailSettingsUpdate = {
|
||||
imap_password: 'newpassword123',
|
||||
smtp_password: 'newsmtppass456',
|
||||
};
|
||||
|
||||
const mockResponse: TicketEmailSettings = {
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
imap_use_ssl: true,
|
||||
imap_username: 'support@example.com',
|
||||
imap_password_masked: '***',
|
||||
imap_folder: 'INBOX',
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_username: 'support@example.com',
|
||||
smtp_password_masked: '***',
|
||||
smtp_from_email: 'support@example.com',
|
||||
smtp_from_name: 'Support Team',
|
||||
support_email_address: 'support@example.com',
|
||||
support_email_domain: 'example.com',
|
||||
is_enabled: true,
|
||||
delete_after_processing: false,
|
||||
check_interval_seconds: 300,
|
||||
max_attachment_size_mb: 10,
|
||||
allowed_attachment_types: ['pdf', 'jpg', 'png'],
|
||||
last_check_at: null,
|
||||
last_error: '',
|
||||
emails_processed_count: 0,
|
||||
is_configured: true,
|
||||
is_imap_configured: true,
|
||||
is_smtp_configured: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-12-07T10:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await updateTicketEmailSettings(updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testImapConnection', () => {
|
||||
it('should call POST /tickets/email-settings/test-imap/', async () => {
|
||||
const mockResult: TestConnectionResult = {
|
||||
success: true,
|
||||
message: 'IMAP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await testImapConnection();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle connection failures', async () => {
|
||||
const mockResult: TestConnectionResult = {
|
||||
success: false,
|
||||
message: 'Failed to connect: Invalid credentials',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await testImapConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to connect');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testSmtpConnection', () => {
|
||||
it('should call POST /tickets/email-settings/test-smtp/', async () => {
|
||||
const mockResult: TestConnectionResult = {
|
||||
success: true,
|
||||
message: 'SMTP connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await testSmtpConnection();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-smtp/');
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle SMTP connection failures', async () => {
|
||||
const mockResult: TestConnectionResult = {
|
||||
success: false,
|
||||
message: 'SMTP error: Connection refused',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await testSmtpConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Connection refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testEmailConnection (legacy alias)', () => {
|
||||
it('should be an alias for testImapConnection', async () => {
|
||||
const mockResult: TestConnectionResult = {
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await testEmailConnection();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEmailsNow', () => {
|
||||
it('should call POST /tickets/email-settings/fetch-now/', async () => {
|
||||
const mockResult: FetchNowResult = {
|
||||
success: true,
|
||||
message: 'Successfully processed 5 emails',
|
||||
processed: 5,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await fetchEmailsNow();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/fetch-now/');
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle no new emails', async () => {
|
||||
const mockResult: FetchNowResult = {
|
||||
success: true,
|
||||
message: 'No new emails found',
|
||||
processed: 0,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await fetchEmailsNow();
|
||||
|
||||
expect(result.processed).toBe(0);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomingEmails', () => {
|
||||
it('should call GET /tickets/incoming-emails/ without params', async () => {
|
||||
const mockEmails: IncomingTicketEmail[] = [
|
||||
{
|
||||
id: 1,
|
||||
message_id: '<msg1@example.com>',
|
||||
from_address: 'customer@example.com',
|
||||
from_name: 'John Doe',
|
||||
to_address: 'support@example.com',
|
||||
subject: 'Help needed',
|
||||
body_text: 'I need assistance with...',
|
||||
extracted_reply: 'I need assistance with...',
|
||||
ticket: 123,
|
||||
ticket_subject: 'Help needed',
|
||||
matched_user: 456,
|
||||
ticket_id_from_email: '#123',
|
||||
processing_status: 'PROCESSED',
|
||||
processing_status_display: 'Processed',
|
||||
error_message: '',
|
||||
email_date: '2025-12-07T09:00:00Z',
|
||||
received_at: '2025-12-07T09:01:00Z',
|
||||
processed_at: '2025-12-07T09:02:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
|
||||
|
||||
const result = await getIncomingEmails();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', { params: undefined });
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockEmails);
|
||||
});
|
||||
|
||||
it('should call GET /tickets/incoming-emails/ with status filter', async () => {
|
||||
const mockEmails: IncomingTicketEmail[] = [];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
|
||||
|
||||
const result = await getIncomingEmails({ status: 'FAILED' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
|
||||
params: { status: 'FAILED' },
|
||||
});
|
||||
expect(result).toEqual(mockEmails);
|
||||
});
|
||||
|
||||
it('should call GET /tickets/incoming-emails/ with ticket filter', async () => {
|
||||
const mockEmails: IncomingTicketEmail[] = [];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
|
||||
|
||||
const result = await getIncomingEmails({ ticket: 123 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
|
||||
params: { ticket: 123 },
|
||||
});
|
||||
expect(result).toEqual(mockEmails);
|
||||
});
|
||||
|
||||
it('should call GET /tickets/incoming-emails/ with multiple filters', async () => {
|
||||
const mockEmails: IncomingTicketEmail[] = [];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
|
||||
|
||||
const result = await getIncomingEmails({ status: 'PROCESSED', ticket: 123 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
|
||||
params: { status: 'PROCESSED', ticket: 123 },
|
||||
});
|
||||
expect(result).toEqual(mockEmails);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reprocessIncomingEmail', () => {
|
||||
it('should call POST /tickets/incoming-emails/:id/reprocess/', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Email reprocessed successfully',
|
||||
comment_id: 789,
|
||||
ticket_id: 123,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await reprocessIncomingEmail(456);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/456/reprocess/');
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle reprocessing failures', async () => {
|
||||
const mockResponse = {
|
||||
success: false,
|
||||
message: 'Failed to reprocess: Invalid email format',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await reprocessIncomingEmail(999);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/999/reprocess/');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to reprocess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEmailProvider', () => {
|
||||
it('should call POST /tickets/email-settings/detect/ with email', async () => {
|
||||
const mockResult: EmailProviderDetectResult = {
|
||||
success: true,
|
||||
email: 'user@gmail.com',
|
||||
domain: 'gmail.com',
|
||||
detected: true,
|
||||
detected_via: 'domain_lookup',
|
||||
provider: 'google',
|
||||
display_name: 'Gmail',
|
||||
imap_host: 'imap.gmail.com',
|
||||
imap_port: 993,
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 587,
|
||||
oauth_supported: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await detectEmailProvider('user@gmail.com');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/detect/', {
|
||||
email: 'user@gmail.com',
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should detect Microsoft provider', async () => {
|
||||
const mockResult: EmailProviderDetectResult = {
|
||||
success: true,
|
||||
email: 'user@outlook.com',
|
||||
domain: 'outlook.com',
|
||||
detected: true,
|
||||
detected_via: 'domain_lookup',
|
||||
provider: 'microsoft',
|
||||
display_name: 'Outlook.com',
|
||||
imap_host: 'outlook.office365.com',
|
||||
imap_port: 993,
|
||||
smtp_host: 'smtp.office365.com',
|
||||
smtp_port: 587,
|
||||
oauth_supported: true,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await detectEmailProvider('user@outlook.com');
|
||||
|
||||
expect(result.provider).toBe('microsoft');
|
||||
expect(result.oauth_supported).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect custom domain via MX records', async () => {
|
||||
const mockResult: EmailProviderDetectResult = {
|
||||
success: true,
|
||||
email: 'admin@company.com',
|
||||
domain: 'company.com',
|
||||
detected: true,
|
||||
detected_via: 'mx_record',
|
||||
provider: 'google',
|
||||
display_name: 'Google Workspace',
|
||||
oauth_supported: true,
|
||||
message: 'Detected Google Workspace via MX records',
|
||||
notes: 'Use OAuth for best security',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await detectEmailProvider('admin@company.com');
|
||||
|
||||
expect(result.detected_via).toBe('mx_record');
|
||||
expect(result.provider).toBe('google');
|
||||
});
|
||||
|
||||
it('should handle unknown provider', async () => {
|
||||
const mockResult: EmailProviderDetectResult = {
|
||||
success: true,
|
||||
email: 'user@custom-server.com',
|
||||
domain: 'custom-server.com',
|
||||
detected: false,
|
||||
provider: 'unknown',
|
||||
display_name: 'Unknown Provider',
|
||||
oauth_supported: false,
|
||||
message: 'Could not auto-detect email provider',
|
||||
suggested_imap_port: 993,
|
||||
suggested_smtp_port: 587,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await detectEmailProvider('user@custom-server.com');
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
expect(result.provider).toBe('unknown');
|
||||
expect(result.oauth_supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOAuthStatus', () => {
|
||||
it('should call GET /oauth/status/', async () => {
|
||||
const mockStatus: OAuthStatusResult = {
|
||||
google: { configured: true },
|
||||
microsoft: { configured: false },
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getOAuthStatus();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/oauth/status/');
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockStatus);
|
||||
});
|
||||
|
||||
it('should handle no OAuth configured', async () => {
|
||||
const mockStatus: OAuthStatusResult = {
|
||||
google: { configured: false },
|
||||
microsoft: { configured: false },
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
|
||||
|
||||
const result = await getOAuthStatus();
|
||||
|
||||
expect(result.google.configured).toBe(false);
|
||||
expect(result.microsoft.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiateGoogleOAuth', () => {
|
||||
it('should call POST /oauth/google/initiate/ with default purpose', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: true,
|
||||
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateGoogleOAuth();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'email' });
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should call POST /oauth/google/initiate/ with custom purpose', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: true,
|
||||
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateGoogleOAuth('calendar');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'calendar' });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle OAuth initiation errors', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: false,
|
||||
error: 'OAuth client credentials not configured',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateGoogleOAuth();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initiateMicrosoftOAuth', () => {
|
||||
it('should call POST /oauth/microsoft/initiate/ with default purpose', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: true,
|
||||
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateMicrosoftOAuth();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', { purpose: 'email' });
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should call POST /oauth/microsoft/initiate/ with custom purpose', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: true,
|
||||
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateMicrosoftOAuth('calendar');
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', {
|
||||
purpose: 'calendar',
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle Microsoft OAuth errors', async () => {
|
||||
const mockResult: OAuthInitiateResult = {
|
||||
success: false,
|
||||
error: 'Microsoft OAuth not configured',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await initiateMicrosoftOAuth();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Microsoft OAuth not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOAuthCredentials', () => {
|
||||
it('should call GET /oauth/credentials/', async () => {
|
||||
const mockCredentials: OAuthCredential[] = [
|
||||
{
|
||||
id: 1,
|
||||
provider: 'google',
|
||||
email: 'support@example.com',
|
||||
purpose: 'email',
|
||||
is_valid: true,
|
||||
is_expired: false,
|
||||
last_used_at: '2025-12-07T09:00:00Z',
|
||||
last_error: '',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
provider: 'microsoft',
|
||||
email: 'admin@example.com',
|
||||
purpose: 'email',
|
||||
is_valid: false,
|
||||
is_expired: true,
|
||||
last_used_at: '2025-11-01T10:00:00Z',
|
||||
last_error: 'Token expired',
|
||||
created_at: '2025-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
|
||||
|
||||
const result = await getOAuthCredentials();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/oauth/credentials/');
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockCredentials);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle empty credentials list', async () => {
|
||||
const mockCredentials: OAuthCredential[] = [];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
|
||||
|
||||
const result = await getOAuthCredentials();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOAuthCredential', () => {
|
||||
it('should call DELETE /oauth/credentials/:id/', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'OAuth credential deleted successfully',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await deleteOAuthCredential(123);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/123/');
|
||||
expect(apiClient.delete).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle deletion of non-existent credential', async () => {
|
||||
const mockResponse = {
|
||||
success: false,
|
||||
message: 'Credential not found',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await deleteOAuthCredential(999);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/999/');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
577
frontend/src/api/__tests__/tickets.test.ts
Normal file
577
frontend/src/api/__tests__/tickets.test.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getTickets,
|
||||
getTicket,
|
||||
createTicket,
|
||||
updateTicket,
|
||||
deleteTicket,
|
||||
getTicketComments,
|
||||
createTicketComment,
|
||||
getTicketTemplates,
|
||||
getTicketTemplate,
|
||||
getCannedResponses,
|
||||
refreshTicketEmails,
|
||||
} from '../tickets';
|
||||
import apiClient from '../client';
|
||||
import type { Ticket, TicketComment, TicketTemplate, CannedResponse } from '../../types';
|
||||
|
||||
describe('tickets API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getTickets', () => {
|
||||
it('fetches all tickets without filters', async () => {
|
||||
const mockTickets: Ticket[] = [
|
||||
{
|
||||
id: '1',
|
||||
creator: 'user1',
|
||||
creatorEmail: 'user1@example.com',
|
||||
creatorFullName: 'User One',
|
||||
ticketType: 'CUSTOMER',
|
||||
status: 'OPEN',
|
||||
priority: 'HIGH',
|
||||
subject: 'Test Ticket',
|
||||
description: 'Test description',
|
||||
category: 'TECHNICAL',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
creator: 'user2',
|
||||
creatorEmail: 'user2@example.com',
|
||||
creatorFullName: 'User Two',
|
||||
ticketType: 'PLATFORM',
|
||||
status: 'IN_PROGRESS',
|
||||
priority: 'MEDIUM',
|
||||
subject: 'Another Ticket',
|
||||
description: 'Another description',
|
||||
category: 'BILLING',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTickets });
|
||||
|
||||
const result = await getTickets();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
|
||||
expect(result).toEqual(mockTickets);
|
||||
});
|
||||
|
||||
it('applies status filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ status: 'OPEN' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=OPEN');
|
||||
});
|
||||
|
||||
it('applies priority filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ priority: 'HIGH' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?priority=HIGH');
|
||||
});
|
||||
|
||||
it('applies category filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ category: 'TECHNICAL' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?category=TECHNICAL');
|
||||
});
|
||||
|
||||
it('applies ticketType filter with snake_case conversion', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ ticketType: 'CUSTOMER' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?ticket_type=CUSTOMER');
|
||||
});
|
||||
|
||||
it('applies assignee filter', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ assignee: 'user123' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?assignee=user123');
|
||||
});
|
||||
|
||||
it('applies multiple filters', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({
|
||||
status: 'OPEN',
|
||||
priority: 'HIGH',
|
||||
category: 'BILLING',
|
||||
ticketType: 'CUSTOMER',
|
||||
assignee: 'user456',
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
'/tickets/?status=OPEN&priority=HIGH&category=BILLING&ticket_type=CUSTOMER&assignee=user456'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies partial filters', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({ status: 'CLOSED', priority: 'LOW' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=CLOSED&priority=LOW');
|
||||
});
|
||||
|
||||
it('handles empty filters object', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await getTickets({});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTicket', () => {
|
||||
it('fetches a single ticket by ID', async () => {
|
||||
const mockTicket: Ticket = {
|
||||
id: '123',
|
||||
creator: 'user1',
|
||||
creatorEmail: 'user1@example.com',
|
||||
creatorFullName: 'User One',
|
||||
assignee: 'user2',
|
||||
assigneeEmail: 'user2@example.com',
|
||||
assigneeFullName: 'User Two',
|
||||
ticketType: 'CUSTOMER',
|
||||
status: 'IN_PROGRESS',
|
||||
priority: 'HIGH',
|
||||
subject: 'Important Ticket',
|
||||
description: 'This needs attention',
|
||||
category: 'TECHNICAL',
|
||||
relatedAppointmentId: 'appt-456',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicket });
|
||||
|
||||
const result = await getTicket('123');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/123/');
|
||||
expect(result).toEqual(mockTicket);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTicket', () => {
|
||||
it('creates a new ticket', async () => {
|
||||
const newTicketData: Partial<Ticket> = {
|
||||
subject: 'New Ticket',
|
||||
description: 'New ticket description',
|
||||
ticketType: 'CUSTOMER',
|
||||
priority: 'MEDIUM',
|
||||
category: 'GENERAL_INQUIRY',
|
||||
};
|
||||
const createdTicket: Ticket = {
|
||||
id: '789',
|
||||
creator: 'current-user',
|
||||
creatorEmail: 'current@example.com',
|
||||
creatorFullName: 'Current User',
|
||||
status: 'OPEN',
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
updatedAt: '2024-01-03T00:00:00Z',
|
||||
...newTicketData,
|
||||
} as Ticket;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: createdTicket });
|
||||
|
||||
const result = await createTicket(newTicketData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
|
||||
expect(result).toEqual(createdTicket);
|
||||
});
|
||||
|
||||
it('creates a ticket with all optional fields', async () => {
|
||||
const newTicketData: Partial<Ticket> = {
|
||||
subject: 'Complex Ticket',
|
||||
description: 'Complex description',
|
||||
ticketType: 'STAFF_REQUEST',
|
||||
priority: 'URGENT',
|
||||
category: 'TIME_OFF',
|
||||
assignee: 'manager-123',
|
||||
relatedAppointmentId: 'appt-999',
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await createTicket(newTicketData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTicket', () => {
|
||||
it('updates a ticket', async () => {
|
||||
const updateData: Partial<Ticket> = {
|
||||
status: 'RESOLVED',
|
||||
priority: 'LOW',
|
||||
};
|
||||
const updatedTicket: Ticket = {
|
||||
id: '123',
|
||||
creator: 'user1',
|
||||
creatorEmail: 'user1@example.com',
|
||||
creatorFullName: 'User One',
|
||||
ticketType: 'CUSTOMER',
|
||||
subject: 'Existing Ticket',
|
||||
description: 'Existing description',
|
||||
category: 'TECHNICAL',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-05T00:00:00Z',
|
||||
...updateData,
|
||||
} as Ticket;
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedTicket });
|
||||
|
||||
const result = await updateTicket('123', updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
|
||||
expect(result).toEqual(updatedTicket);
|
||||
});
|
||||
|
||||
it('updates ticket assignee', async () => {
|
||||
const updateData = { assignee: 'new-assignee-456' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
||||
|
||||
await updateTicket('123', updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
|
||||
});
|
||||
|
||||
it('updates multiple ticket fields', async () => {
|
||||
const updateData: Partial<Ticket> = {
|
||||
status: 'CLOSED',
|
||||
priority: 'LOW',
|
||||
assignee: 'user789',
|
||||
category: 'RESOLVED',
|
||||
};
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
||||
|
||||
await updateTicket('456', updateData);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/456/', updateData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTicket', () => {
|
||||
it('deletes a ticket', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
await deleteTicket('123');
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/123/');
|
||||
});
|
||||
|
||||
it('returns void', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({});
|
||||
|
||||
const result = await deleteTicket('456');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTicketComments', () => {
|
||||
it('fetches all comments for a ticket', async () => {
|
||||
const mockComments: TicketComment[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
ticket: 't1',
|
||||
author: 'user1',
|
||||
authorEmail: 'user1@example.com',
|
||||
authorFullName: 'User One',
|
||||
commentText: 'First comment',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
isInternal: false,
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
ticket: 't1',
|
||||
author: 'user2',
|
||||
authorEmail: 'user2@example.com',
|
||||
authorFullName: 'User Two',
|
||||
commentText: 'Second comment',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
isInternal: true,
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockComments });
|
||||
|
||||
const result = await getTicketComments('t1');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t1/comments/');
|
||||
expect(result).toEqual(mockComments);
|
||||
});
|
||||
|
||||
it('handles ticket with no comments', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getTicketComments('t999');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t999/comments/');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTicketComment', () => {
|
||||
it('creates a new comment on a ticket', async () => {
|
||||
const commentData: Partial<TicketComment> = {
|
||||
commentText: 'This is a new comment',
|
||||
isInternal: false,
|
||||
};
|
||||
const createdComment: TicketComment = {
|
||||
id: 'c123',
|
||||
ticket: 't1',
|
||||
author: 'current-user',
|
||||
authorEmail: 'current@example.com',
|
||||
authorFullName: 'Current User',
|
||||
createdAt: '2024-01-03T00:00:00Z',
|
||||
...commentData,
|
||||
} as TicketComment;
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: createdComment });
|
||||
|
||||
const result = await createTicketComment('t1', commentData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t1/comments/', commentData);
|
||||
expect(result).toEqual(createdComment);
|
||||
});
|
||||
|
||||
it('creates an internal comment', async () => {
|
||||
const commentData: Partial<TicketComment> = {
|
||||
commentText: 'Internal note',
|
||||
isInternal: true,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await createTicketComment('t2', commentData);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t2/comments/', commentData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTicketTemplates', () => {
|
||||
it('fetches all ticket templates', async () => {
|
||||
const mockTemplates: TicketTemplate[] = [
|
||||
{
|
||||
id: 'tmpl1',
|
||||
name: 'Bug Report Template',
|
||||
description: 'Template for bug reports',
|
||||
ticketType: 'CUSTOMER',
|
||||
category: 'TECHNICAL',
|
||||
defaultPriority: 'HIGH',
|
||||
subjectTemplate: 'Bug: {{title}}',
|
||||
descriptionTemplate: 'Steps to reproduce:\n{{steps}}',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'tmpl2',
|
||||
tenant: 'tenant123',
|
||||
name: 'Time Off Request',
|
||||
description: 'Staff time off template',
|
||||
ticketType: 'STAFF_REQUEST',
|
||||
category: 'TIME_OFF',
|
||||
defaultPriority: 'MEDIUM',
|
||||
subjectTemplate: 'Time Off: {{dates}}',
|
||||
descriptionTemplate: 'Reason:\n{{reason}}',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
|
||||
|
||||
const result = await getTicketTemplates();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/');
|
||||
expect(result).toEqual(mockTemplates);
|
||||
});
|
||||
|
||||
it('handles empty template list', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getTicketTemplates();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTicketTemplate', () => {
|
||||
it('fetches a single ticket template by ID', async () => {
|
||||
const mockTemplate: TicketTemplate = {
|
||||
id: 'tmpl123',
|
||||
name: 'Feature Request Template',
|
||||
description: 'Template for feature requests',
|
||||
ticketType: 'CUSTOMER',
|
||||
category: 'FEATURE_REQUEST',
|
||||
defaultPriority: 'LOW',
|
||||
subjectTemplate: 'Feature Request: {{feature}}',
|
||||
descriptionTemplate: 'Description:\n{{description}}\n\nBenefit:\n{{benefit}}',
|
||||
isActive: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
|
||||
|
||||
const result = await getTicketTemplate('tmpl123');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/tmpl123/');
|
||||
expect(result).toEqual(mockTemplate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCannedResponses', () => {
|
||||
it('fetches all canned responses', async () => {
|
||||
const mockResponses: CannedResponse[] = [
|
||||
{
|
||||
id: 'cr1',
|
||||
title: 'Thank You Response',
|
||||
content: 'Thank you for contacting us. We will get back to you soon.',
|
||||
category: 'GENERAL_INQUIRY',
|
||||
isActive: true,
|
||||
useCount: 42,
|
||||
createdBy: 'admin',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'cr2',
|
||||
tenant: 'tenant456',
|
||||
title: 'Billing Issue',
|
||||
content: 'We have received your billing inquiry and are investigating.',
|
||||
category: 'BILLING',
|
||||
isActive: true,
|
||||
useCount: 18,
|
||||
createdBy: 'manager',
|
||||
createdAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponses });
|
||||
|
||||
const result = await getCannedResponses();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/tickets/canned-responses/');
|
||||
expect(result).toEqual(mockResponses);
|
||||
});
|
||||
|
||||
it('handles empty canned responses list', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const result = await getCannedResponses();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshTicketEmails', () => {
|
||||
it('successfully refreshes ticket emails', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
processed: 5,
|
||||
results: [
|
||||
{
|
||||
address: 'support@example.com',
|
||||
display_name: 'Support',
|
||||
processed: 3,
|
||||
status: 'success',
|
||||
last_check_at: '2024-01-05T12:00:00Z',
|
||||
},
|
||||
{
|
||||
address: 'help@example.com',
|
||||
display_name: 'Help Desk',
|
||||
processed: 2,
|
||||
status: 'success',
|
||||
last_check_at: '2024-01-05T12:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await refreshTicketEmails();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/tickets/refresh-emails/');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processed).toBe(5);
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles refresh with errors', async () => {
|
||||
const mockResult = {
|
||||
success: false,
|
||||
processed: 0,
|
||||
results: [
|
||||
{
|
||||
address: 'invalid@example.com',
|
||||
display_name: 'Invalid Email',
|
||||
status: 'error',
|
||||
error: 'Connection timeout',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await refreshTicketEmails();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.processed).toBe(0);
|
||||
expect(result.results[0].status).toBe('error');
|
||||
expect(result.results[0].error).toBe('Connection timeout');
|
||||
});
|
||||
|
||||
it('handles partial success', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
processed: 2,
|
||||
results: [
|
||||
{
|
||||
address: 'working@example.com',
|
||||
processed: 2,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
address: null,
|
||||
status: 'skipped',
|
||||
message: 'No email address configured',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await refreshTicketEmails();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.processed).toBe(2);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0].status).toBe('success');
|
||||
expect(result.results[1].status).toBe('skipped');
|
||||
});
|
||||
|
||||
it('handles no configured email addresses', async () => {
|
||||
const mockResult = {
|
||||
success: false,
|
||||
processed: 0,
|
||||
results: [],
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
|
||||
|
||||
const result = await refreshTicketEmails();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.processed).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface LoginResponse {
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
can_send_messages?: boolean;
|
||||
};
|
||||
masquerade_stack?: MasqueradeStackEntry[];
|
||||
// MFA challenge response
|
||||
@@ -72,6 +73,9 @@ export interface User {
|
||||
permissions?: Record<string, boolean>;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
can_send_messages?: boolean;
|
||||
linked_resource_id?: number;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
@@ -132,3 +136,11 @@ export const stopMasquerade = async (
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request password reset email
|
||||
*/
|
||||
export const forgotPassword = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post<{ message: string }>('/auth/password-reset/', { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
184
frontend/src/api/billing.ts
Normal file
184
frontend/src/api/billing.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -33,6 +33,24 @@ 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;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessUpdate {
|
||||
@@ -41,11 +59,38 @@ export interface PlatformBusinessUpdate {
|
||||
subscription_tier?: string;
|
||||
max_users?: number;
|
||||
max_resources?: 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_process_refunds?: boolean;
|
||||
can_create_packages?: boolean;
|
||||
can_use_email_templates?: boolean;
|
||||
can_customize_booking_page?: boolean;
|
||||
advanced_reporting?: boolean;
|
||||
priority_support?: boolean;
|
||||
dedicated_support?: boolean;
|
||||
sso_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBusinessCreate {
|
||||
@@ -116,6 +161,14 @@ export const createBusiness = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a business/tenant (platform admin only)
|
||||
* This permanently deletes the tenant and all associated data
|
||||
*/
|
||||
export const deleteBusiness = async (businessId: number): Promise<void> => {
|
||||
await apiClient.delete(`/platform/businesses/${businessId}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users (platform admin only)
|
||||
*/
|
||||
|
||||
446
frontend/src/components/AddPaymentMethodModal.tsx
Normal file
446
frontend/src/components/AddPaymentMethodModal.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Add Payment Method Modal Component
|
||||
*
|
||||
* Uses Stripe Elements with SetupIntent to securely save card details
|
||||
* without charging the customer.
|
||||
*
|
||||
* For Stripe Connect, we must initialize Stripe with the connected account ID
|
||||
* so the SetupIntent (created on the connected account) can be confirmed.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { loadStripe, Stripe } from '@stripe/stripe-js';
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateSetupIntent, useSetDefaultPaymentMethod, useCustomerPaymentMethods } from '../hooks/useCustomerBilling';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Cache for Stripe instances per connected account
|
||||
// Note: Module-level cache persists across component re-renders but not page reloads
|
||||
const stripeInstanceCache: Record<string, Promise<Stripe | null>> = {};
|
||||
|
||||
// Clear cache entry (useful for debugging)
|
||||
export const clearStripeCache = (key?: string) => {
|
||||
if (key) {
|
||||
delete stripeInstanceCache[key];
|
||||
} else {
|
||||
Object.keys(stripeInstanceCache).forEach(k => delete stripeInstanceCache[k]);
|
||||
}
|
||||
};
|
||||
|
||||
// Get or create Stripe instance for a connected account (or platform account if empty)
|
||||
// For direct_api mode, customPublishableKey will be the tenant's key
|
||||
// For connect mode, we use the platform's key with stripeAccount
|
||||
const getStripeInstance = (
|
||||
stripeAccount: string,
|
||||
customPublishableKey?: string
|
||||
): Promise<Stripe | null> => {
|
||||
// Use custom key for direct_api mode, platform key for connect mode
|
||||
const publishableKey = customPublishableKey || import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '';
|
||||
// Use 'platform' as cache key for direct_api mode (empty stripeAccount)
|
||||
// For direct_api with custom key, include key in cache to avoid conflicts
|
||||
const cacheKey = customPublishableKey
|
||||
? `direct_${customPublishableKey.substring(0, 20)}`
|
||||
: (stripeAccount || 'platform');
|
||||
|
||||
console.log('[AddPaymentMethodModal] getStripeInstance called with:', {
|
||||
stripeAccount: stripeAccount || '(empty - direct_api mode)',
|
||||
cacheKey,
|
||||
publishableKey: publishableKey.substring(0, 20) + '...',
|
||||
isDirectApi: !!customPublishableKey,
|
||||
});
|
||||
|
||||
if (!stripeInstanceCache[cacheKey]) {
|
||||
console.log('[AddPaymentMethodModal] Creating new Stripe instance for:', cacheKey);
|
||||
// Only pass stripeAccount option if it's not empty (connect mode)
|
||||
// For direct_api mode, we use the tenant's own API keys (no connected account needed)
|
||||
stripeInstanceCache[cacheKey] = stripeAccount
|
||||
? loadStripe(publishableKey, { stripeAccount })
|
||||
: loadStripe(publishableKey);
|
||||
} else {
|
||||
console.log('[AddPaymentMethodModal] Using cached Stripe instance for:', cacheKey);
|
||||
}
|
||||
|
||||
return stripeInstanceCache[cacheKey];
|
||||
};
|
||||
|
||||
interface CardFormProps {
|
||||
clientSecret: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CardFormInner: React.FC<CardFormProps> = ({
|
||||
clientSecret,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const queryClient = useQueryClient();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [cardComplete, setCardComplete] = useState(false);
|
||||
|
||||
// Get current payment methods to check if this is the first one
|
||||
const { data: paymentMethodsData } = useCustomerPaymentMethods();
|
||||
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
|
||||
|
||||
// Detect dark mode for Stripe CardElement styling
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
if (!cardElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Confirm the SetupIntent with Stripe
|
||||
const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, {
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || t('billing.addCardFailed', 'Failed to add card. Please try again.'));
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupIntent && setupIntent.status === 'succeeded') {
|
||||
// Get the payment method ID from the setup intent
|
||||
const paymentMethodId = typeof setupIntent.payment_method === 'string'
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
|
||||
// Check if there's already a default payment method
|
||||
const existingMethods = paymentMethodsData?.payment_methods;
|
||||
const hasDefaultMethod = existingMethods?.some(pm => pm.is_default) ?? false;
|
||||
|
||||
console.log('[AddPaymentMethodModal] SetupIntent succeeded:', {
|
||||
paymentMethodId,
|
||||
existingMethodsCount: existingMethods?.length ?? 0,
|
||||
hasDefaultMethod,
|
||||
});
|
||||
|
||||
// Set as default if no default payment method exists yet
|
||||
if (!hasDefaultMethod && paymentMethodId) {
|
||||
console.log('[AddPaymentMethodModal] No default payment method exists, setting new one as default:', paymentMethodId);
|
||||
// Set as default (fire and forget - don't block the success flow)
|
||||
setDefaultPaymentMethod.mutate(paymentMethodId, {
|
||||
onSuccess: () => {
|
||||
console.log('[AddPaymentMethodModal] Successfully set payment method as default');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('[AddPaymentMethodModal] Failed to set default payment method:', err);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log('[AddPaymentMethodModal] Default already exists or no paymentMethodId - existingMethods:', existingMethods?.length, 'hasDefaultMethod:', hasDefaultMethod, 'paymentMethodId:', paymentMethodId);
|
||||
}
|
||||
|
||||
// Invalidate payment methods to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
|
||||
setIsComplete(true);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err.message || t('billing.unexpectedError', 'An unexpected error occurred.'));
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('billing.cardAdded', 'Card Added Successfully!')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('billing.cardAddedDescription', 'Your payment method has been saved.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: isDarkMode ? '#f1f5f9' : '#1e293b',
|
||||
iconColor: isDarkMode ? '#94a3b8' : '#64748b',
|
||||
'::placeholder': {
|
||||
color: isDarkMode ? '#64748b' : '#94a3b8',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: isDarkMode ? '#f87171' : '#dc2626',
|
||||
iconColor: isDarkMode ? '#f87171' : '#dc2626',
|
||||
},
|
||||
},
|
||||
}}
|
||||
onChange={(e) => setCardComplete(e.complete)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isProcessing || !cardComplete}
|
||||
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('common.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{t('billing.saveCard', 'Save Card')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddPaymentMethodModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const AddPaymentMethodModal: React.FC<AddPaymentMethodModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [stripeAccount, setStripeAccount] = useState<string | null>(null);
|
||||
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const createSetupIntent = useCreateSetupIntent();
|
||||
|
||||
// Detect dark mode for Stripe Elements appearance
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains('dark'));
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !clientSecret && !createSetupIntent.isPending) {
|
||||
// Create SetupIntent when modal opens
|
||||
createSetupIntent.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
console.log('[AddPaymentMethodModal] SetupIntent response:', {
|
||||
client_secret: data.client_secret?.substring(0, 30) + '...',
|
||||
setup_intent_id: data.setup_intent_id,
|
||||
customer_id: data.customer_id,
|
||||
stripe_account: data.stripe_account,
|
||||
publishable_key: data.publishable_key ? data.publishable_key.substring(0, 20) + '...' : null,
|
||||
});
|
||||
|
||||
// stripe_account can be empty string for direct_api mode, or acct_xxx for connect mode
|
||||
// Only undefined/null indicates an error
|
||||
if (data.stripe_account === undefined || data.stripe_account === null) {
|
||||
console.error('[AddPaymentMethodModal] stripe_account is undefined/null - payment system may not be configured correctly');
|
||||
setError(t('billing.paymentSystemNotConfigured', 'The payment system is not fully configured. Please contact support.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setClientSecret(data.client_secret);
|
||||
setStripeAccount(data.stripe_account);
|
||||
// Load Stripe - empty stripe_account means direct_api mode (use tenant's publishable_key)
|
||||
// Non-empty stripe_account means connect mode (use platform key with connected account)
|
||||
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('[AddPaymentMethodModal] SetupIntent error:', err);
|
||||
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setClientSecret(null);
|
||||
setStripeAccount(null);
|
||||
setStripePromise(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('billing.addPaymentMethod', 'Add Payment Method')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createSetupIntent.isPending ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('common.loading', 'Loading...')}
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
createSetupIntent.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
setClientSecret(data.client_secret);
|
||||
setStripeAccount(data.stripe_account);
|
||||
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
{t('common.tryAgain', 'Try Again')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : clientSecret && stripePromise ? (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: isDarkMode ? 'night' : 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#2563eb',
|
||||
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
|
||||
colorText: isDarkMode ? '#f1f5f9' : '#1e293b',
|
||||
colorDanger: isDarkMode ? '#f87171' : '#dc2626',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
spacingUnit: '12px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardFormInner
|
||||
clientSecret={clientSecret}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</Elements>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPaymentMethodModal;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
|
||||
|
||||
@@ -48,11 +49,13 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'info',
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const config = variantConfig[variant];
|
||||
@@ -95,7 +98,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
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}
|
||||
{cancelText || t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
@@ -120,7 +123,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{confirmText}
|
||||
{confirmText || t('common.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
@@ -27,6 +28,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
tier,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onboardingMutation = useConnectOnboarding();
|
||||
@@ -53,7 +55,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
// Redirect to Stripe onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to start onboarding');
|
||||
setError(err.response?.data?.error || t('payments.failedToStartOnboarding'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,7 +67,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
// Redirect to continue onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
|
||||
setError(err.response?.data?.error || t('payments.failedToRefreshLink'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,13 +75,13 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
return t('payments.standardConnect');
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
return t('payments.expressConnect');
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
return t('payments.customConnect');
|
||||
default:
|
||||
return 'Connect';
|
||||
return t('payments.connect');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,9 +93,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800">Stripe Connected</h4>
|
||||
<h4 className="font-medium text-green-800">{t('payments.stripeConnected')}</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
{t('payments.stripeConnectedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,14 +105,14 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
{/* Account Details */}
|
||||
{connectAccount && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
|
||||
<h4 className="font-medium text-gray-900 mb-3">{t('payments.accountDetails')}</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Type:</span>
|
||||
<span className="text-gray-600">{t('payments.accountType')}:</span>
|
||||
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="text-gray-600">{t('payments.status')}:</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
connectAccount.status === 'active'
|
||||
@@ -126,40 +128,40 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Charges:</span>
|
||||
<span className="text-gray-600">{t('payments.charges')}:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.charges_enabled ? (
|
||||
<>
|
||||
<CreditCard size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
<span className="text-green-600">{t('payments.enabled')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
<span className="text-gray-500">{t('payments.disabled')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Payouts:</span>
|
||||
<span className="text-gray-600">{t('payments.payouts')}:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.payouts_enabled ? (
|
||||
<>
|
||||
<Wallet size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
<span className="text-green-600">{t('payments.enabled')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wallet size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
<span className="text-gray-500">{t('payments.disabled')}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{connectAccount.stripe_account_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account ID:</span>
|
||||
<span className="text-gray-600">{t('payments.accountId')}:</span>
|
||||
<code className="font-mono text-gray-900 text-xs">
|
||||
{connectAccount.stripe_account_id}
|
||||
</code>
|
||||
@@ -175,10 +177,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
|
||||
<h4 className="font-medium text-yellow-800">{t('payments.completeOnboarding')}</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Your Stripe Connect account setup is incomplete.
|
||||
Click below to continue the onboarding process.
|
||||
{t('payments.onboardingIncomplete')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRefreshLink}
|
||||
@@ -190,7 +191,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Continue Onboarding
|
||||
{t('payments.continueOnboarding')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,24 +202,22 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
{needsOnboarding && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
|
||||
<h4 className="font-medium text-blue-800 mb-2">{t('payments.connectWithStripe')}</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
This provides a seamless payment experience for your customers while
|
||||
the platform handles payment processing.
|
||||
{t('payments.tierPaymentDescription', { tier })}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
{t('payments.securePaymentProcessing')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
{t('payments.automaticPayouts')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
{t('payments.pciCompliance')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -233,7 +232,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={18} />
|
||||
Connect with Stripe
|
||||
{t('payments.connectWithStripe')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -259,7 +258,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open Stripe Dashboard
|
||||
{t('payments.openStripeDashboard')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Wallet,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
||||
|
||||
interface ConnectOnboardingEmbedProps {
|
||||
@@ -37,6 +38,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -68,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
colorDanger: '#df1b41',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSizeBase: '14px',
|
||||
spacingUnit: '4px',
|
||||
spacingUnit: '12px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
@@ -78,12 +80,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
setLoadingState('ready');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to initialize Stripe Connect:', err);
|
||||
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
|
||||
const message = err.response?.data?.error || err.message || t('payments.failedToInitializePayment');
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}
|
||||
}, [loadingState, onError]);
|
||||
}, [loadingState, onError, t]);
|
||||
|
||||
// Handle onboarding completion
|
||||
const handleOnboardingExit = useCallback(async () => {
|
||||
@@ -100,23 +102,23 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
// Handle errors from the Connect component
|
||||
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
||||
console.error('Connect component load error:', loadError);
|
||||
const message = loadError.error.message || 'Failed to load payment component';
|
||||
const message = loadError.error.message || t('payments.failedToLoadPaymentComponent');
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}, [onError]);
|
||||
}, [onError, t]);
|
||||
|
||||
// Account type display
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
return t('payments.standardConnect');
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
return t('payments.expressConnect');
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
return t('payments.customConnect');
|
||||
default:
|
||||
return 'Connect';
|
||||
return t('payments.connect');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,39 +130,39 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
|
||||
<h4 className="font-medium text-green-800 dark:text-green-300">{t('payments.stripeConnected')}</h4>
|
||||
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
{t('payments.stripeConnectedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('payments.accountDetails')}</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Account Type:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.accountType')}:</span>
|
||||
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.status')}:</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
{connectAccount.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 dark:text-gray-400">Charges:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.charges')}:</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<CreditCard size={14} />
|
||||
Enabled
|
||||
{t('payments.enabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600 dark:text-gray-400">Payouts:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.payouts')}:</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Wallet size={14} />
|
||||
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
|
||||
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,9 +176,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
return (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
|
||||
<CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
|
||||
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
|
||||
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">{t('payments.onboardingComplete')}</h4>
|
||||
<p className="text-sm text-green-700 dark:text-green-400 mt-2">
|
||||
Your Stripe account has been set up. You can now accept payments.
|
||||
{t('payments.stripeSetupComplete')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -190,7 +192,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
|
||||
<h4 className="font-medium text-red-800 dark:text-red-300">{t('payments.setupFailed')}</h4>
|
||||
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,7 +204,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Try Again
|
||||
{t('payments.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -216,23 +218,22 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
|
||||
<h4 className="font-medium text-blue-800 dark:text-blue-300">{t('payments.setUpPayments')}</h4>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
Complete the onboarding process to start accepting payments from your customers.
|
||||
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
{t('payments.securePaymentProcessing')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
{t('payments.automaticPayouts')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
{t('payments.pciCompliance')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -244,7 +245,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
Start Payment Setup
|
||||
{t('payments.startPaymentSetup')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -255,7 +256,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
|
||||
<p className="text-gray-600 dark:text-gray-400">Initializing payment setup...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('payments.initializingPaymentSetup')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -265,10 +266,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Complete Your Account Setup</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('payments.completeAccountSetup')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fill out the information below to finish setting up your payment account.
|
||||
Your information is securely handled by Stripe.
|
||||
{t('payments.fillOutInfoForPayment')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
colorText: '#1e293b',
|
||||
colorDanger: '#dc2626',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
spacingUnit: '4px',
|
||||
spacingUnit: '12px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
||||
|
||||
export interface TestUser {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
label: string;
|
||||
@@ -14,56 +14,56 @@ export interface TestUser {
|
||||
|
||||
const testUsers: TestUser[] = [
|
||||
{
|
||||
username: 'superuser',
|
||||
email: 'superuser@platform.com',
|
||||
password: 'test123',
|
||||
role: 'SUPERUSER',
|
||||
label: 'Platform Superuser',
|
||||
color: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_manager',
|
||||
email: 'manager@platform.com',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_MANAGER',
|
||||
label: 'Platform Manager',
|
||||
color: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_sales',
|
||||
email: 'sales@platform.com',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SALES',
|
||||
label: 'Platform Sales',
|
||||
color: 'bg-green-600 hover:bg-green-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_support',
|
||||
email: 'support@platform.com',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SUPPORT',
|
||||
label: 'Platform Support',
|
||||
color: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_owner',
|
||||
email: 'owner@demo.com',
|
||||
password: 'test123',
|
||||
role: 'TENANT_OWNER',
|
||||
label: 'Business Owner',
|
||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_manager',
|
||||
email: 'manager@demo.com',
|
||||
password: 'test123',
|
||||
role: 'TENANT_MANAGER',
|
||||
label: 'Business Manager',
|
||||
color: 'bg-pink-600 hover:bg-pink-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_staff',
|
||||
email: 'staff@demo.com',
|
||||
password: 'test123',
|
||||
role: 'TENANT_STAFF',
|
||||
label: 'Staff Member',
|
||||
color: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
{
|
||||
username: 'customer',
|
||||
email: 'customer@demo.com',
|
||||
password: 'test123',
|
||||
role: 'CUSTOMER',
|
||||
label: 'Customer',
|
||||
@@ -86,16 +86,16 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
||||
}
|
||||
|
||||
const handleQuickLogin = async (user: TestUser) => {
|
||||
setLoading(user.username);
|
||||
setLoading(user.email);
|
||||
try {
|
||||
// Call token auth API
|
||||
const response = await apiClient.post('/auth-token/', {
|
||||
username: user.username,
|
||||
// Call custom login API that supports email login
|
||||
const response = await apiClient.post('/auth/login/', {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||
setCookie('access_token', response.data.token, 7);
|
||||
setCookie('access_token', response.data.access, 7);
|
||||
|
||||
// Clear any existing masquerade stack - this is a fresh login
|
||||
localStorage.removeItem('masquerade_stack');
|
||||
@@ -176,12 +176,12 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{testUsers.map((user) => (
|
||||
<button
|
||||
key={user.username}
|
||||
key={user.email}
|
||||
onClick={() => handleQuickLogin(user)}
|
||||
disabled={loading !== null}
|
||||
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{loading === user.username ? (
|
||||
{loading === user.email ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||
<circle
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search,
|
||||
Globe,
|
||||
@@ -26,6 +27,7 @@ interface DomainPurchaseProps {
|
||||
type Step = 'search' | 'details' | 'confirm';
|
||||
|
||||
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>('search');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
|
||||
@@ -138,7 +140,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">Search</span>
|
||||
<span className="text-sm font-medium">{t('common.search')}</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div
|
||||
@@ -155,7 +157,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">Details</span>
|
||||
<span className="text-sm font-medium">{t('settings.domain.details')}</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div
|
||||
@@ -172,7 +174,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">Confirm</span>
|
||||
<span className="text-sm font-medium">{t('common.confirm')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +188,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Enter domain name or keyword..."
|
||||
placeholder={t('settings.domain.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -200,14 +202,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
) : (
|
||||
<Search className="h-5 w-5" />
|
||||
)}
|
||||
Search
|
||||
{t('common.search')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Search Results</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.searchResults')}</h4>
|
||||
<div className="space-y-2">
|
||||
{searchResults.map((result) => (
|
||||
<div
|
||||
@@ -230,7 +232,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</span>
|
||||
{result.premium && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded">
|
||||
Premium
|
||||
{t('settings.domain.premium')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,12 +248,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded-lg hover:bg-brand-700 flex items-center gap-2"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Select
|
||||
{t('settings.domain.select')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!result.available && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Unavailable</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('settings.domain.unavailable')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,7 +266,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
{registeredDomains && registeredDomains.length > 0 && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Your Registered Domains
|
||||
{t('settings.domain.yourRegisteredDomains')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{registeredDomains.map((domain) => (
|
||||
@@ -289,7 +291,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
{domain.expires_at && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Expires: {new Date(domain.expires_at).toLocaleDateString()}
|
||||
{t('settings.domain.expires')}: {new Date(domain.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,7 +318,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
onClick={() => setStep('search')}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
Change
|
||||
{t('settings.domain.change')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +327,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Registration Period
|
||||
{t('payments.registrationPeriod')}
|
||||
</label>
|
||||
<select
|
||||
value={years}
|
||||
@@ -334,7 +336,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
>
|
||||
{[1, 2, 3, 5, 10].map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y} {y === 1 ? 'year' : 'years'} - $
|
||||
{y} {y === 1 ? t('settings.domain.year') : t('settings.domain.years')} - $
|
||||
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
|
||||
</option>
|
||||
))}
|
||||
@@ -355,10 +357,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
<Shield className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
WHOIS Privacy Protection
|
||||
{t('settings.domain.whoisPrivacy')}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Hide your personal information from public WHOIS lookups
|
||||
{t('settings.domain.whoisPrivacyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,9 +376,9 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">Auto-Renewal</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">{t('settings.domain.autoRenewal')}</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically renew this domain before it expires
|
||||
{t('settings.domain.autoRenewalDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,10 +395,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
<Globe className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
Auto-configure as Custom Domain
|
||||
{t('settings.domain.autoConfigure')}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatically set up this domain for your business
|
||||
{t('settings.domain.autoConfigureDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,12 +408,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
{/* Contact Information */}
|
||||
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Registrant Information
|
||||
{t('settings.domain.registrantInfo')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name *
|
||||
{t('settings.domain.firstName')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -423,7 +425,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name *
|
||||
{t('settings.domain.lastName')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -435,7 +437,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email *
|
||||
{t('customers.email')} *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -447,7 +449,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Phone *
|
||||
{t('customers.phone')} *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
@@ -460,7 +462,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Address *
|
||||
{t('customers.address')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -472,7 +474,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
City *
|
||||
{t('customers.city')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -484,7 +486,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
State/Province *
|
||||
{t('settings.domain.stateProvince')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -496,7 +498,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
ZIP/Postal Code *
|
||||
{t('settings.domain.zipPostalCode')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -508,19 +510,19 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Country *
|
||||
{t('settings.domain.country')} *
|
||||
</label>
|
||||
<select
|
||||
value={contact.country}
|
||||
onChange={(e) => updateContact('country', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="AU">Australia</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="US">{t('settings.domain.countries.us')}</option>
|
||||
<option value="CA">{t('settings.domain.countries.ca')}</option>
|
||||
<option value="GB">{t('settings.domain.countries.gb')}</option>
|
||||
<option value="AU">{t('settings.domain.countries.au')}</option>
|
||||
<option value="DE">{t('settings.domain.countries.de')}</option>
|
||||
<option value="FR">{t('settings.domain.countries.fr')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,14 +534,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
onClick={() => setStep('search')}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStep('confirm')}
|
||||
disabled={!isContactValid()}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
{t('settings.domain.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,36 +550,36 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 'confirm' && selectedDomain && (
|
||||
<div className="space-y-6">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Order Summary</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{t('payments.orderSummary')}</h4>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Domain</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.domain')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{selectedDomain.domain}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Registration Period</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.registrationPeriod')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{years} {years === 1 ? 'year' : 'years'}
|
||||
{years} {years === 1 ? t('settings.domain.year') : t('settings.domain.years')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">WHOIS Privacy</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.whoisPrivacy')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{whoisPrivacy ? 'Enabled' : 'Disabled'}
|
||||
{whoisPrivacy ? t('platform.settings.enabled') : t('platform.settings.none')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Auto-Renewal</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('settings.domain.autoRenewal')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{autoRenew ? 'Enabled' : 'Disabled'}
|
||||
{autoRenew ? t('platform.settings.enabled') : t('platform.settings.none')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Total</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{t('settings.domain.total')}</span>
|
||||
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
|
||||
${getPrice().toFixed(2)}
|
||||
</span>
|
||||
@@ -587,7 +589,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
|
||||
{/* Registrant Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Registrant</h5>
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-2">{t('settings.domain.registrant')}</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.first_name} {contact.last_name}
|
||||
<br />
|
||||
@@ -602,7 +604,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
{registerMutation.isError && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Registration failed. Please try again.</span>
|
||||
<span>{t('payments.registrationFailed')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -612,7 +614,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
onClick={() => setStep('details')}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePurchase}
|
||||
@@ -624,7 +626,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||
) : (
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
)}
|
||||
Complete Purchase
|
||||
{t('settings.domain.completePurchase')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from '../api/client';
|
||||
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
||||
import { formatLocalDate } from '../utils/dateUtils';
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string;
|
||||
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
|
||||
setScheduleMode('onetime');
|
||||
if (task.run_at) {
|
||||
const date = new Date(task.run_at);
|
||||
setRunAtDate(date.toISOString().split('T')[0]);
|
||||
setRunAtDate(formatLocalDate(date));
|
||||
setRunAtTime(date.toTimeString().slice(0, 5));
|
||||
}
|
||||
} else if (task.schedule_type === 'INTERVAL') {
|
||||
|
||||
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;
|
||||
98
frontend/src/components/FloatingHelpButton.tsx
Normal file
98
frontend/src/components/FloatingHelpButton.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* FloatingHelpButton Component
|
||||
*
|
||||
* A floating help button fixed in the top-right corner of the screen.
|
||||
* Automatically determines the help path based on the current route.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Map routes to their help paths
|
||||
const routeToHelpPath: Record<string, string> = {
|
||||
'/': '/help/dashboard',
|
||||
'/dashboard': '/help/dashboard',
|
||||
'/scheduler': '/help/scheduler',
|
||||
'/tasks': '/help/tasks',
|
||||
'/customers': '/help/customers',
|
||||
'/services': '/help/services',
|
||||
'/resources': '/help/resources',
|
||||
'/staff': '/help/staff',
|
||||
'/time-blocks': '/help/time-blocks',
|
||||
'/my-availability': '/help/time-blocks',
|
||||
'/messages': '/help/messages',
|
||||
'/tickets': '/help/ticketing',
|
||||
'/payments': '/help/payments',
|
||||
'/contracts': '/help/contracts',
|
||||
'/contracts/templates': '/help/contracts',
|
||||
'/plugins': '/help/plugins',
|
||||
'/plugins/marketplace': '/help/plugins',
|
||||
'/plugins/my-plugins': '/help/plugins',
|
||||
'/plugins/create': '/help/plugins/create',
|
||||
'/settings': '/help/settings/general',
|
||||
'/settings/general': '/help/settings/general',
|
||||
'/settings/resource-types': '/help/settings/resource-types',
|
||||
'/settings/booking': '/help/settings/booking',
|
||||
'/settings/appearance': '/help/settings/appearance',
|
||||
'/settings/email': '/help/settings/email',
|
||||
'/settings/domains': '/help/settings/domains',
|
||||
'/settings/api': '/help/settings/api',
|
||||
'/settings/auth': '/help/settings/auth',
|
||||
'/settings/billing': '/help/settings/billing',
|
||||
'/settings/quota': '/help/settings/quota',
|
||||
// Platform routes
|
||||
'/platform/dashboard': '/help/dashboard',
|
||||
'/platform/businesses': '/help/dashboard',
|
||||
'/platform/users': '/help/staff',
|
||||
'/platform/tickets': '/help/ticketing',
|
||||
};
|
||||
|
||||
const FloatingHelpButton: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Get the help path for the current route
|
||||
const getHelpPath = (): string => {
|
||||
// Exact match first
|
||||
if (routeToHelpPath[location.pathname]) {
|
||||
return routeToHelpPath[location.pathname];
|
||||
}
|
||||
|
||||
// Try matching with a prefix (for dynamic routes like /customers/:id)
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
if (pathSegments.length > 0) {
|
||||
// Try progressively shorter paths
|
||||
for (let i = pathSegments.length; i > 0; i--) {
|
||||
const testPath = '/' + pathSegments.slice(0, i).join('/');
|
||||
if (routeToHelpPath[testPath]) {
|
||||
return routeToHelpPath[testPath];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the main help guide
|
||||
return '/help';
|
||||
};
|
||||
|
||||
const helpPath = getHelpPath();
|
||||
|
||||
// Don't show on help pages themselves
|
||||
if (location.pathname.startsWith('/help')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={helpPath}
|
||||
className="fixed top-20 right-4 z-50 inline-flex items-center justify-center w-10 h-10 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full shadow-lg border border-gray-200 dark:border-gray-700 transition-all duration-200 hover:scale-110"
|
||||
title={t('common.help', 'Help')}
|
||||
aria-label={t('common.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingHelpButton;
|
||||
33
frontend/src/components/HelpButton.tsx
Normal file
33
frontend/src/components/HelpButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* HelpButton Component
|
||||
*
|
||||
* A contextual help button that appears at the top-right of pages
|
||||
* and links to the relevant help documentation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HelpButtonProps {
|
||||
helpPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HelpButton: React.FC<HelpButtonProps> = ({ helpPath, className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={helpPath}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors ${className}`}
|
||||
title={t('common.help', 'Help')}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
<span className="hidden sm:inline">{t('common.help', 'Help')}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpButton;
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Eye, XCircle } from 'lucide-react';
|
||||
import { User } from '../types';
|
||||
|
||||
@@ -11,8 +12,9 @@ interface MasqueradeBannerProps {
|
||||
}
|
||||
|
||||
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
|
||||
|
||||
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttonText = previousUser ? t('platform.masquerade.returnTo', { name: previousUser.name }) : t('platform.masquerade.stopMasquerading');
|
||||
|
||||
return (
|
||||
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
|
||||
@@ -21,9 +23,9 @@ const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, orig
|
||||
<Eye size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
|
||||
<span className="opacity-75 mx-2 text-xs">|</span>
|
||||
Logged in as {originalUser.name}
|
||||
{t('platform.masquerade.masqueradingAs')} <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
|
||||
<span className="opacity-75 mx-2 text-xs">|</span>
|
||||
{t('platform.masquerade.loggedInAs', { name: originalUser.name })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadNotificationCount,
|
||||
@@ -56,6 +56,14 @@ 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');
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to target if available
|
||||
if (notification.target_url) {
|
||||
navigate(notification.target_url);
|
||||
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
clearAllMutation.mutate();
|
||||
};
|
||||
|
||||
const getNotificationIcon = (targetType: string | null) => {
|
||||
switch (targetType) {
|
||||
const getNotificationIcon = (notification: Notification) => {
|
||||
// Check for time-off request type in data (new or modified)
|
||||
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
|
||||
return <Clock size={16} className="text-amber-500" />;
|
||||
}
|
||||
|
||||
switch (notification.target_type) {
|
||||
case 'ticket':
|
||||
return <Ticket size={16} className="text-blue-500" />;
|
||||
case 'event':
|
||||
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getNotificationIcon(notification.target_type)}
|
||||
{getNotificationIcon(notification)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
|
||||
|
||||
268
frontend/src/components/QuotaOverageModal.tsx
Normal file
268
frontend/src/components/QuotaOverageModal.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* QuotaOverageModal Component
|
||||
*
|
||||
* Modal that appears on login/masquerade when the tenant has exceeded quotas.
|
||||
* Shows warning about grace period and what will happen when it expires.
|
||||
* Uses sessionStorage to only show once per session.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
X,
|
||||
Clock,
|
||||
Archive,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Layers,
|
||||
Briefcase,
|
||||
Mail,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { QuotaOverage } from '../api/auth';
|
||||
|
||||
interface QuotaOverageModalProps {
|
||||
overages: QuotaOverage[];
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const QUOTA_ICONS: Record<string, React.ReactNode> = {
|
||||
'MAX_ADDITIONAL_USERS': <Users className="w-5 h-5" />,
|
||||
'MAX_RESOURCES': <Layers className="w-5 h-5" />,
|
||||
'MAX_SERVICES': <Briefcase className="w-5 h-5" />,
|
||||
'MAX_EMAIL_TEMPLATES': <Mail className="w-5 h-5" />,
|
||||
'MAX_AUTOMATED_TASKS': <Zap className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const SESSION_STORAGE_KEY = 'quota_overage_modal_dismissed';
|
||||
|
||||
const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already dismissed this session
|
||||
const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!dismissed && overages && overages.length > 0) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, [overages]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
|
||||
setIsVisible(false);
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
if (!isVisible || !overages || overages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the most urgent overage (least days remaining)
|
||||
const mostUrgent = overages.reduce((prev, curr) =>
|
||||
curr.days_remaining < prev.days_remaining ? curr : prev
|
||||
);
|
||||
|
||||
const isCritical = mostUrgent.days_remaining <= 1;
|
||||
const isUrgent = mostUrgent.days_remaining <= 7;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className={`px-6 py-4 ${
|
||||
isCritical
|
||||
? 'bg-red-600'
|
||||
: isUrgent
|
||||
? 'bg-amber-500'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-full ${
|
||||
isCritical || isUrgent
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-200 dark:bg-amber-800'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-6 h-6 ${
|
||||
isCritical || isUrgent
|
||||
? 'text-white'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-lg font-bold ${
|
||||
isCritical || isUrgent
|
||||
? 'text-white'
|
||||
: 'text-amber-900 dark:text-amber-100'
|
||||
}`}>
|
||||
{isCritical
|
||||
? t('quota.modal.titleCritical', 'Action Required Immediately!')
|
||||
: isUrgent
|
||||
? t('quota.modal.titleUrgent', 'Action Required Soon')
|
||||
: t('quota.modal.title', 'Quota Exceeded')
|
||||
}
|
||||
</h2>
|
||||
<p className={`text-sm ${
|
||||
isCritical || isUrgent
|
||||
? 'text-white/90'
|
||||
: 'text-amber-700 dark:text-amber-200'
|
||||
}`}>
|
||||
{mostUrgent.days_remaining <= 0
|
||||
? t('quota.modal.subtitleExpired', 'Grace period has expired')
|
||||
: mostUrgent.days_remaining === 1
|
||||
? t('quota.modal.subtitleOneDay', '1 day remaining')
|
||||
: t('quota.modal.subtitle', '{{days}} days remaining', { days: mostUrgent.days_remaining })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isCritical || isUrgent
|
||||
? 'hover:bg-white/20 text-white'
|
||||
: 'hover:bg-amber-200 dark:hover:bg-amber-800 text-amber-700 dark:text-amber-300'
|
||||
}`}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-5 space-y-5">
|
||||
{/* Main message */}
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-500 dark:text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<p className="font-medium mb-1">
|
||||
{t('quota.modal.gracePeriodEnds', 'Grace period ends on {{date}}', {
|
||||
date: formatDate(mostUrgent.grace_period_ends_at)
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('quota.modal.explanation',
|
||||
'Your account has exceeded its plan limits. Please remove or archive excess items before the grace period ends, or they will be automatically archived.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overage list */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('quota.modal.overagesTitle', 'Items Over Quota')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{overages.map((overage) => (
|
||||
<div
|
||||
key={overage.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
overage.days_remaining <= 1
|
||||
? 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
||||
: overage.days_remaining <= 7
|
||||
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
overage.days_remaining <= 1
|
||||
? 'bg-red-100 dark:bg-red-800/50 text-red-600 dark:text-red-400'
|
||||
: overage.days_remaining <= 7
|
||||
? 'bg-amber-100 dark:bg-amber-800/50 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
{QUOTA_ICONS[overage.quota_type] || <Layers className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{overage.display_name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('quota.modal.usageInfo', '{{current}} used / {{limit}} allowed', {
|
||||
current: overage.current_usage,
|
||||
limit: overage.allowed_limit
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-bold ${
|
||||
overage.days_remaining <= 1
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: overage.days_remaining <= 7
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
+{overage.overage_amount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('quota.modal.overLimit', 'over limit')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What happens section */}
|
||||
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<Archive className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">
|
||||
{t('quota.modal.whatHappens', 'What happens if I don\'t take action?')}
|
||||
</p>
|
||||
<p>
|
||||
{t('quota.modal.autoArchiveExplanation',
|
||||
'After the grace period ends, the oldest items over your limit will be automatically archived. Archived items remain in your account but cannot be used until you upgrade or remove other items.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('quota.modal.dismissButton', 'Remind Me Later')}
|
||||
</button>
|
||||
<Link
|
||||
to="/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"
|
||||
>
|
||||
{t('quota.modal.manageButton', 'Manage Quota')}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaOverageModal;
|
||||
|
||||
/**
|
||||
* Clear the session storage dismissal flag
|
||||
* Call this when user logs out or masquerade changes
|
||||
*/
|
||||
export const resetQuotaOverageModalDismissal = () => {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
|
||||
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
|
||||
@@ -28,6 +29,7 @@ interface ResourceCalendarProps {
|
||||
}
|
||||
|
||||
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
@@ -712,12 +714,12 @@ const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourc
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-gray-400 dark:text-gray-500">Loading appointments...</p>
|
||||
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.loadingAppointments')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && appointments.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-gray-400 dark:text-gray-500">No appointments scheduled for this period</p>
|
||||
<p className="text-gray-400 dark:text-gray-500">{t('scheduler.noAppointmentsScheduled')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
325
frontend/src/components/ResourceDetailModal.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Resource Detail Modal
|
||||
*
|
||||
* Shows resource details including a map of the staff member's
|
||||
* current location when they are en route or in progress.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GoogleMap, useJsApiLoader, Marker } from '@react-google-maps/api';
|
||||
import { Resource } from '../types';
|
||||
import { useResourceLocation, useLiveResourceLocation } from '../hooks/useResourceLocation';
|
||||
import Portal from './Portal';
|
||||
import {
|
||||
X,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
Activity,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ResourceDetailModalProps {
|
||||
resource: Resource;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const mapContainerStyle = {
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
|
||||
const defaultCenter = {
|
||||
lat: 39.8283, // Center of US
|
||||
lng: -98.5795,
|
||||
};
|
||||
|
||||
const ResourceDetailModal: React.FC<ResourceDetailModalProps> = ({ resource, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '';
|
||||
const hasApiKey = googleMapsApiKey.length > 0;
|
||||
|
||||
// Fetch location data
|
||||
const { data: location, isLoading, error } = useResourceLocation(resource.id);
|
||||
|
||||
// Connect to live updates when tracking is active
|
||||
useLiveResourceLocation(resource.id, {
|
||||
enabled: location?.isTracking === true,
|
||||
});
|
||||
|
||||
// Load Google Maps API only if we have a key
|
||||
// When no API key, we skip the hook entirely to avoid warnings
|
||||
const shouldLoadMaps = hasApiKey;
|
||||
const { isLoaded: mapsLoaded, loadError: mapsLoadError } = useJsApiLoader({
|
||||
googleMapsApiKey: shouldLoadMaps ? googleMapsApiKey : 'SKIP_LOADING',
|
||||
});
|
||||
|
||||
// Treat missing API key as if maps failed to load
|
||||
const effectiveMapsLoaded = shouldLoadMaps && mapsLoaded;
|
||||
const effectiveMapsError = !shouldLoadMaps || mapsLoadError;
|
||||
|
||||
// Map center based on location
|
||||
const mapCenter = useMemo(() => {
|
||||
if (location?.hasLocation && location.latitude && location.longitude) {
|
||||
return { lat: location.latitude, lng: location.longitude };
|
||||
}
|
||||
return defaultCenter;
|
||||
}, [location]);
|
||||
|
||||
// Format timestamp
|
||||
const formattedTimestamp = useMemo(() => {
|
||||
if (!location?.timestamp) return null;
|
||||
const date = new Date(location.timestamp);
|
||||
return date.toLocaleString();
|
||||
}, [location?.timestamp]);
|
||||
|
||||
// Status color based on job status
|
||||
const statusColor = useMemo(() => {
|
||||
if (!location?.activeJob) return 'gray';
|
||||
switch (location.activeJob.status) {
|
||||
case 'EN_ROUTE':
|
||||
return 'yellow';
|
||||
case 'IN_PROGRESS':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}, [location?.activeJob]);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<UserIcon size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{resource.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.staffMember', 'Staff Member')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="sr-only">{t('common.close')}</span>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Active Job Status */}
|
||||
{location?.activeJob && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
statusColor === 'yellow'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
: statusColor === 'blue'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity size={20} className={
|
||||
statusColor === 'yellow'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: statusColor === 'blue'
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{location.activeJob.statusDisplay}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{location.activeJob.title}
|
||||
</div>
|
||||
</div>
|
||||
{location.isTracking && (
|
||||
<span className="ml-auto inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
{t('resources.liveTracking', 'Live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||
<MapPin size={16} />
|
||||
{t('resources.currentLocation', 'Current Location')}
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-[300px] bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle size={32} className="text-red-400 mx-auto mb-2" />
|
||||
<p className="text-red-600 dark:text-red-400">{t('resources.locationError', 'Failed to load location')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !location?.hasLocation ? (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin size={32} className="text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{location?.message || t('resources.noLocationData', 'No location data available')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{t('resources.locationHint', 'Location will appear when staff is en route')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveMapsError ? (
|
||||
// Fallback when Google Maps isn't available - show coordinates
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg p-6">
|
||||
<div className="h-full flex flex-col items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4">
|
||||
<Navigation size={32} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('resources.gpsCoordinates', 'GPS Coordinates')}
|
||||
</p>
|
||||
<p className="text-lg font-medium text-gray-900 dark:text-white mb-1">
|
||||
{location.latitude?.toFixed(6)}, {location.longitude?.toFixed(6)}
|
||||
</p>
|
||||
{location.speed !== undefined && location.speed !== null && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.speed', 'Speed')}: {(location.speed * 2.237).toFixed(1)} mph
|
||||
</p>
|
||||
)}
|
||||
{location.heading !== undefined && location.heading !== null && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('resources.heading', 'Heading')}: {location.heading.toFixed(0)}°
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${location.latitude},${location.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
{t('resources.openInMaps', 'Open in Google Maps')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveMapsLoaded ? (
|
||||
<GoogleMap
|
||||
mapContainerStyle={mapContainerStyle}
|
||||
center={mapCenter}
|
||||
zoom={15}
|
||||
options={{
|
||||
disableDefaultUI: false,
|
||||
zoomControl: true,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true,
|
||||
}}
|
||||
>
|
||||
{location.latitude && location.longitude && (
|
||||
<Marker
|
||||
position={{ lat: location.latitude, lng: location.longitude }}
|
||||
title={resource.name}
|
||||
icon={{
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 10,
|
||||
fillColor: location.isTracking ? '#22c55e' : '#3b82f6',
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</GoogleMap>
|
||||
) : (
|
||||
<div className="h-[300px] bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<Loader2 size={32} className="text-gray-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location Details */}
|
||||
{location?.hasLocation && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{t('resources.lastUpdate', 'Last Update')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formattedTimestamp || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{location.accuracy && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.accuracy', 'Accuracy')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{location.accuracy < 1000
|
||||
? `${location.accuracy.toFixed(0)}m`
|
||||
: `${(location.accuracy / 1000).toFixed(1)}km`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{location.speed !== undefined && location.speed !== null && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.speed', 'Speed')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{(location.speed * 2.237).toFixed(1)} mph
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{location.heading !== undefined && location.heading !== null && (
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t('resources.heading', 'Heading')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{location.heading.toFixed(0)}°
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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 transition-colors"
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceDetailModal;
|
||||
@@ -4,7 +4,9 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { clsx } from 'clsx';
|
||||
import { Clock, DollarSign } from 'lucide-react';
|
||||
|
||||
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||
// Import from types.ts for consistency
|
||||
import type { AppointmentStatus } from '../../types';
|
||||
export type { AppointmentStatus };
|
||||
|
||||
export interface DraggableEventProps {
|
||||
id: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Clock, GripVertical } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PendingAppointment {
|
||||
id: number;
|
||||
@@ -15,6 +16,7 @@ interface PendingItemProps {
|
||||
}
|
||||
|
||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `pending-${appointment.id}`,
|
||||
data: {
|
||||
@@ -43,7 +45,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
|
||||
<Clock size={10} />
|
||||
<span>{appointment.durationMinutes} min</span>
|
||||
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -54,16 +56,18 @@ interface PendingSidebarProps {
|
||||
}
|
||||
|
||||
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-100">
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2">
|
||||
<Clock size={12} /> Pending Requests ({appointments.length})
|
||||
<Clock size={12} /> {t('scheduler.pendingRequests')} ({appointments.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto flex-1">
|
||||
{appointments.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
|
||||
) : (
|
||||
appointments.map(apt => (
|
||||
<PendingItem key={apt.id} appointment={apt} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Clock, GripVertical, Trash2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PendingAppointment {
|
||||
id: number;
|
||||
@@ -22,6 +23,7 @@ interface PendingItemProps {
|
||||
}
|
||||
|
||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `pending-${appointment.id}`,
|
||||
data: {
|
||||
@@ -50,7 +52,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<Clock size={10} />
|
||||
<span>{appointment.durationMinutes} min</span>
|
||||
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -63,11 +65,13 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: 250 }}>
|
||||
{/* Resources Header */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: 48 }}>
|
||||
Resources
|
||||
{t('scheduler.resources')}
|
||||
</div>
|
||||
|
||||
{/* Resources List (Synced Scroll) */}
|
||||
@@ -89,10 +93,10 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resourceName}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
|
||||
Resource
|
||||
{t('scheduler.resource')}
|
||||
{layout.laneCount > 1 && (
|
||||
<span className="text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/50 px-1 rounded text-[10px]">
|
||||
{layout.laneCount} lanes
|
||||
{layout.laneCount} {t('scheduler.lanes')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -106,11 +110,11 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
|
||||
{/* Pending Requests (Fixed Bottom) */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200">
|
||||
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0">
|
||||
<Clock size={12} /> Pending Requests ({pendingAppointments.length})
|
||||
<Clock size={12} /> {t('scheduler.pendingRequests')} ({pendingAppointments.length})
|
||||
</h3>
|
||||
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
|
||||
{pendingAppointments.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>
|
||||
<div className="text-xs text-gray-400 italic text-center py-4">{t('scheduler.noPendingRequests')}</div>
|
||||
) : (
|
||||
pendingAppointments.map(apt => (
|
||||
<PendingItem key={apt.id} appointment={apt} />
|
||||
@@ -122,7 +126,7 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
|
||||
<div className="shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 opacity-50">
|
||||
<div className="flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700 bg-transparent text-gray-400">
|
||||
<Trash2 size={16} />
|
||||
<span className="text-xs font-medium">Drop here to archive</span>
|
||||
<span className="text-xs font-medium">{t('scheduler.dropToArchive')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
921
frontend/src/components/Schedule/__tests__/Sidebar.test.tsx
Normal file
921
frontend/src/components/Schedule/__tests__/Sidebar.test.tsx
Normal file
@@ -0,0 +1,921 @@
|
||||
/**
|
||||
* Unit tests for Sidebar component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - Resources list display
|
||||
* - Pending appointments list
|
||||
* - Empty state handling
|
||||
* - Drag source setup with @dnd-kit
|
||||
* - Scrolling reference setup
|
||||
* - Multi-lane resource badges
|
||||
* - Archive drop zone display
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { DndContext } from '@dnd-kit/core';
|
||||
import React from 'react';
|
||||
import Sidebar, { PendingAppointment, ResourceLayout } from '../Sidebar';
|
||||
|
||||
// Setup proper mocks for @dnd-kit
|
||||
beforeAll(() => {
|
||||
// Mock IntersectionObserver properly as a constructor
|
||||
class IntersectionObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
constructor() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
global.IntersectionObserver = IntersectionObserverMock as any;
|
||||
|
||||
// Mock ResizeObserver properly as a constructor
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
constructor() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
global.ResizeObserver = ResizeObserverMock as any;
|
||||
});
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'scheduler.resources': 'Resources',
|
||||
'scheduler.resource': 'Resource',
|
||||
'scheduler.lanes': 'lanes',
|
||||
'scheduler.pendingRequests': 'Pending Requests',
|
||||
'scheduler.noPendingRequests': 'No pending requests',
|
||||
'scheduler.dropToArchive': 'Drop here to archive',
|
||||
'scheduler.min': 'min',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper function to create a wrapper with DndContext
|
||||
const createDndWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<DndContext>{children}</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Sidebar', () => {
|
||||
const mockScrollRef = { current: null } as React.RefObject<HTMLDivElement>;
|
||||
|
||||
const mockResourceLayouts: ResourceLayout[] = [
|
||||
{
|
||||
resourceId: 1,
|
||||
resourceName: 'Dr. Smith',
|
||||
height: 100,
|
||||
laneCount: 1,
|
||||
},
|
||||
{
|
||||
resourceId: 2,
|
||||
resourceName: 'Conference Room A',
|
||||
height: 120,
|
||||
laneCount: 2,
|
||||
},
|
||||
{
|
||||
resourceId: 3,
|
||||
resourceName: 'Equipment Bay',
|
||||
height: 100,
|
||||
laneCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const mockPendingAppointments: PendingAppointment[] = [
|
||||
{
|
||||
id: 1,
|
||||
customerName: 'John Doe',
|
||||
serviceName: 'Consultation',
|
||||
durationMinutes: 30,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
customerName: 'Jane Smith',
|
||||
serviceName: 'Follow-up',
|
||||
durationMinutes: 15,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
customerName: 'Bob Johnson',
|
||||
serviceName: 'Initial Assessment',
|
||||
durationMinutes: 60,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the sidebar container', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass('flex', 'flex-col', 'bg-white');
|
||||
});
|
||||
|
||||
it('should render with fixed width of 250px', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveStyle({ width: '250px' });
|
||||
});
|
||||
|
||||
it('should render resources header', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const header = screen.getByText('Resources');
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render pending requests section', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const pendingHeader = screen.getByText(/Pending Requests/);
|
||||
expect(pendingHeader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render archive drop zone', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const dropZone = screen.getByText('Drop here to archive');
|
||||
expect(dropZone).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resources List', () => {
|
||||
it('should render all resources from resourceLayouts', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct height to each resource row', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// 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' });
|
||||
});
|
||||
|
||||
it('should display "Resource" label for each resource', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const resourceLabels = screen.getAllByText('Resource');
|
||||
expect(resourceLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render grip icons for resources', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const gripIcons = container.querySelectorAll('svg');
|
||||
expect(gripIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not render lane count badge for single-lane resources', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[mockResourceLayouts[0]]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/lanes/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render lane count badge for multi-lane resources', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[mockResourceLayouts[1]]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('2 lanes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all multi-lane badges correctly', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('2 lanes')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 lanes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct styling to multi-lane badges', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[mockResourceLayouts[1]]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const badge = screen.getByText('2 lanes');
|
||||
expect(badge).toHaveClass('text-blue-600', 'bg-blue-50');
|
||||
});
|
||||
|
||||
it('should attach scroll ref to resource list container', () => {
|
||||
const testRef = React.createRef<HTMLDivElement>();
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={testRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(testRef.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
|
||||
it('should render empty resources list when no resources provided', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pending Appointments List', () => {
|
||||
it('should render all pending appointments', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display customer names correctly', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
mockPendingAppointments.forEach((apt) => {
|
||||
expect(screen.getByText(apt.customerName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display service names correctly', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Consultation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Follow-up')).toBeInTheDocument();
|
||||
expect(screen.getByText('Initial Assessment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display duration in minutes for each appointment', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument();
|
||||
expect(screen.getByText('15 min')).toBeInTheDocument();
|
||||
expect(screen.getByText('60 min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display clock icon for each appointment', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// Clock icons are SVGs
|
||||
const clockIcons = container.querySelectorAll('svg');
|
||||
expect(clockIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display grip vertical icon for drag handle', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
it('should show appointment count in header', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update count when appointments change', () => {
|
||||
const { rerender } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Pending Requests \(1\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should display empty message when no pending appointments', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('No pending requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show count of 0 in header when empty', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply italic styling to empty message', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const emptyMessage = screen.getByText('No pending requests');
|
||||
expect(emptyMessage).toHaveClass('italic');
|
||||
});
|
||||
|
||||
it('should not render appointment items when empty', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drag and Drop Setup', () => {
|
||||
it('should setup draggable for each pending appointment', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// Each appointment should have drag cursor classes
|
||||
const appointments = container.querySelectorAll('[class*="cursor-grab"]');
|
||||
expect(appointments.length).toBe(mockPendingAppointments.length);
|
||||
});
|
||||
|
||||
it('should apply cursor-grab class to draggable items', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// 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', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// 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', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// 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', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// Use the specific class selector
|
||||
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
|
||||
expect(appointmentCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Archive Drop Zone', () => {
|
||||
it('should render drop zone with trash icon', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const dropZone = screen.getByText('Drop here to archive').parentElement;
|
||||
const trashIcon = dropZone?.querySelector('svg');
|
||||
expect(trashIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply dashed border to drop zone', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const dropZone = screen.getByText('Drop here to archive').parentElement;
|
||||
expect(dropZone).toHaveClass('border-dashed');
|
||||
});
|
||||
|
||||
it('should apply opacity to drop zone container', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const dropZoneContainer = screen
|
||||
.getByText('Drop here to archive')
|
||||
.closest('.opacity-50');
|
||||
expect(dropZoneContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Styling', () => {
|
||||
it('should apply fixed height to resources header', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// The height style is on the header div itself
|
||||
const header = screen.getByText('Resources').closest('[style*="height"]');
|
||||
expect(header).toHaveStyle({ height: '48px' });
|
||||
});
|
||||
|
||||
it('should apply fixed height to pending requests section', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const pendingSection = screen
|
||||
.getByText(/Pending Requests/)
|
||||
.closest('.h-80');
|
||||
expect(pendingSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have overflow-hidden on resource list', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const resourceList = container.querySelector('.overflow-hidden');
|
||||
expect(resourceList).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have overflow-y-auto on pending appointments list', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const pendingList = container.querySelector('.overflow-y-auto');
|
||||
expect(pendingList).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply border-right to sidebar', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('border-r');
|
||||
});
|
||||
|
||||
it('should apply shadow to sidebar', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('shadow-lg');
|
||||
});
|
||||
|
||||
it('should have dark mode classes', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('dark:bg-gray-800');
|
||||
expect(sidebar).toHaveClass('dark:border-gray-700');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for resources header', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for pending requests header', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Pending Requests/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for empty state message', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('No pending requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for drop zone text', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for duration units', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={[mockPendingAppointments[0]]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('30 min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for resource label', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[mockResourceLayouts[0]]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Resource')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for lanes label', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[mockResourceLayouts[1]]}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('2 lanes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props together', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
// Verify resources
|
||||
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Conference Room A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Equipment Bay')).toBeInTheDocument();
|
||||
|
||||
// Verify pending appointments
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||
|
||||
// Verify count
|
||||
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
|
||||
|
||||
// Verify archive drop zone
|
||||
expect(screen.getByText('Drop here to archive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty resources with full pending appointments', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={[]}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Dr. Smith')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Pending Requests \(3\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle full resources with empty pending appointments', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={[]}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pending requests')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Pending Requests \(0\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure with resources and pending sections', () => {
|
||||
const { container } = render(
|
||||
<Sidebar
|
||||
resourceLayouts={mockResourceLayouts}
|
||||
pendingAppointments={mockPendingAppointments}
|
||||
scrollRef={mockScrollRef}
|
||||
/>,
|
||||
{ wrapper: createDndWrapper() }
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
|
||||
// Should have header, resources list, and pending section
|
||||
const sections = sidebar.querySelectorAll(
|
||||
'.border-b, .border-t, .flex-col'
|
||||
);
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
750
frontend/src/components/Schedule/__tests__/Timeline.test.tsx
Normal file
750
frontend/src/components/Schedule/__tests__/Timeline.test.tsx
Normal file
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* Comprehensive unit tests for Timeline component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - Time slots display for different view modes (day, week, month)
|
||||
* - Resource rows display with proper heights
|
||||
* - Events positioned correctly on timeline
|
||||
* - Current time indicator visibility and position
|
||||
* - Date navigation controls
|
||||
* - View mode switching
|
||||
* - Zoom functionality
|
||||
* - Drag and drop interactions
|
||||
* - Scroll synchronization between sidebar and timeline
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import Timeline from '../Timeline';
|
||||
import * as apiClient from '../../../api/client';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock DnD Kit - simplified for testing
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(() => []),
|
||||
PointerSensor: vi.fn(),
|
||||
useDroppable: vi.fn(() => ({
|
||||
setNodeRef: vi.fn(),
|
||||
isOver: false,
|
||||
})),
|
||||
useDraggable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
isDragging: false,
|
||||
})),
|
||||
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../../Timeline/TimelineRow', () => ({
|
||||
default: ({ resourceId, events, height }: any) => (
|
||||
<div
|
||||
data-testid={`timeline-row-${resourceId}`}
|
||||
data-event-count={events.length}
|
||||
style={{ height }}
|
||||
>
|
||||
{events.map((event: any) => (
|
||||
<div key={event.id} data-testid={`event-${event.id}`}>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../Timeline/CurrentTimeIndicator', () => ({
|
||||
default: ({ startTime, hourWidth }: any) => (
|
||||
<div
|
||||
id="current-time-indicator"
|
||||
data-testid="current-time-indicator"
|
||||
data-start-time={startTime.toISOString()}
|
||||
data-hour-width={hourWidth}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../Sidebar', () => ({
|
||||
default: ({ resourceLayouts, pendingAppointments }: any) => (
|
||||
<div data-testid="sidebar">
|
||||
<div data-testid="resource-count">{resourceLayouts.length}</div>
|
||||
<div data-testid="pending-count">{pendingAppointments.length}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Test data
|
||||
const mockResources = [
|
||||
{ id: 1, name: 'Resource 1', type: 'STAFF' },
|
||||
{ id: 2, name: 'Resource 2', type: 'ROOM' },
|
||||
{ id: 3, name: 'Resource 3', type: 'EQUIPMENT' },
|
||||
];
|
||||
|
||||
const mockAppointments = [
|
||||
{
|
||||
id: 1,
|
||||
resource: 1,
|
||||
customer: 101,
|
||||
service: 201,
|
||||
customer_name: 'John Doe',
|
||||
service_name: 'Haircut',
|
||||
start_time: new Date('2025-12-07T10:00:00').toISOString(),
|
||||
end_time: new Date('2025-12-07T11:00:00').toISOString(),
|
||||
status: 'CONFIRMED' as const,
|
||||
is_paid: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
resource: 1,
|
||||
customer: 102,
|
||||
service: 202,
|
||||
customer_name: 'Jane Smith',
|
||||
service_name: 'Coloring',
|
||||
start_time: new Date('2025-12-07T11:30:00').toISOString(),
|
||||
end_time: new Date('2025-12-07T13:00:00').toISOString(),
|
||||
status: 'CONFIRMED' as const,
|
||||
is_paid: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
resource: undefined, // Pending appointment - no resource assigned
|
||||
customer: 103,
|
||||
service: 203,
|
||||
customer_name: 'Bob Johnson',
|
||||
service_name: 'Massage',
|
||||
start_time: new Date('2025-12-07T14:00:00').toISOString(),
|
||||
end_time: new Date('2025-12-07T15:00:00').toISOString(),
|
||||
status: 'PENDING' as const,
|
||||
is_paid: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Test wrapper with Query Client
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Timeline Component', () => {
|
||||
let mockGet: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet = vi.mocked(apiClient.default.get);
|
||||
|
||||
// Default API responses
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.resolve({ data: mockResources });
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.resolve({ data: mockAppointments });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render the timeline component', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display header bar with controls', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
||||
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch resources from API', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/resources/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch appointments from API', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGet).toHaveBeenCalledWith('/appointments/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Slots Rendering', () => {
|
||||
it('should render 24 hour slots in day view', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for some time labels
|
||||
expect(screen.getByText('12 AM')).toBeInTheDocument();
|
||||
expect(screen.getByText('6 AM')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 PM')).toBeInTheDocument();
|
||||
expect(screen.getByText('6 PM')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all 24 hours with correct spacing in day view', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const headerRow = container.querySelector('.sticky.top-0');
|
||||
expect(headerRow).toBeInTheDocument();
|
||||
|
||||
// Should have 24 time slots
|
||||
const timeSlots = headerRow?.querySelectorAll('[style*="width"]');
|
||||
expect(timeSlots?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render day headers in week view', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('day')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const weekButton = screen.getByRole('button', { name: /week/i });
|
||||
await user.click(weekButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Week view should show day names
|
||||
const container = screen.getByRole('button', { name: /week/i }).closest('div')?.parentElement?.parentElement?.parentElement;
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display date range label for current view', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show day view date format
|
||||
const dateLabel = screen.getByText(/December/i);
|
||||
expect(dateLabel).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Rows Display', () => {
|
||||
it('should render resource rows for all resources', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeline-row-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeline-row-3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display correct number of resources in sidebar', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const resourceCount = screen.getByTestId('resource-count');
|
||||
expect(resourceCount).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate row heights based on event lanes', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const row1 = screen.getByTestId('timeline-row-1');
|
||||
// Row 1 has 2 events, should have calculated height
|
||||
expect(row1).toHaveAttribute('style');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle resources with no events', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.resolve({ data: mockResources });
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('timeline-row-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeline-row-1')).toHaveAttribute('data-event-count', '0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events Positioning', () => {
|
||||
it('should render events on their assigned resources', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const row1 = screen.getByTestId('timeline-row-1');
|
||||
expect(row1).toHaveAttribute('data-event-count', '2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display event titles correctly', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter events by resource', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const row1 = screen.getByTestId('timeline-row-1');
|
||||
const row2 = screen.getByTestId('timeline-row-2');
|
||||
|
||||
expect(row1).toHaveAttribute('data-event-count', '2');
|
||||
expect(row2).toHaveAttribute('data-event-count', '0');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle overlapping events with lane calculation', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Both events are on resource 1, should be in timeline
|
||||
expect(screen.getByTestId('event-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('event-2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Current Time Indicator', () => {
|
||||
it('should render current time indicator', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct props to current time indicator', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const indicator = screen.getByTestId('current-time-indicator');
|
||||
expect(indicator).toHaveAttribute('data-start-time');
|
||||
expect(indicator).toHaveAttribute('data-hour-width');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct id for auto-scroll', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const indicator = screen.getByTestId('current-time-indicator');
|
||||
expect(indicator).toHaveAttribute('id', 'current-time-indicator');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date Navigation', () => {
|
||||
it('should have previous and next navigation buttons', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to previous day when clicking previous button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const previousButton = screen.getByTitle('Previous');
|
||||
await user.click(previousButton);
|
||||
|
||||
// Date should change (we can't easily test exact date without exposing state)
|
||||
expect(previousButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to next day when clicking next button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nextButton = screen.getByTitle('Next');
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current date range', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show a date with calendar icon
|
||||
const dateDisplay = screen.getByText(/2025/);
|
||||
expect(dateDisplay).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('View Mode Switching', () => {
|
||||
it('should render view mode buttons (day, week, month)', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should highlight active view mode (day by default)', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const dayButton = screen.getByRole('button', { name: /day/i });
|
||||
expect(dayButton).toHaveClass('bg-blue-500');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to week view when clicking week button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const weekButton = screen.getByRole('button', { name: /week/i });
|
||||
await user.click(weekButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(weekButton).toHaveClass('bg-blue-500');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to month view when clicking month button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const monthButton = screen.getByRole('button', { name: /month/i });
|
||||
await user.click(monthButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(monthButton).toHaveClass('bg-blue-500');
|
||||
});
|
||||
});
|
||||
|
||||
it('should only have one active view mode at a time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /week/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const weekButton = screen.getByRole('button', { name: /week/i });
|
||||
await user.click(weekButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const dayButton = screen.getByRole('button', { name: /day/i });
|
||||
expect(weekButton).toHaveClass('bg-blue-500');
|
||||
expect(dayButton).not.toHaveClass('bg-blue-500');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zoom Functionality', () => {
|
||||
it('should render zoom in and zoom out buttons', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Look for Zoom label and buttons
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Zoom buttons are rendered via Lucide icons
|
||||
const zoomSection = screen.getByText('Zoom').parentElement;
|
||||
expect(zoomSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should increase zoom when clicking zoom in button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find zoom in button (second button after Zoom label)
|
||||
const zoomSection = screen.getByText('Zoom').parentElement;
|
||||
const buttons = zoomSection?.querySelectorAll('button');
|
||||
const zoomInButton = buttons?.[1];
|
||||
|
||||
if (zoomInButton) {
|
||||
await user.click(zoomInButton);
|
||||
// Component should still be rendered
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should decrease zoom when clicking zoom out button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const zoomSection = screen.getByText('Zoom').parentElement;
|
||||
const buttons = zoomSection?.querySelectorAll('button');
|
||||
const zoomOutButton = buttons?.[0];
|
||||
|
||||
if (zoomOutButton) {
|
||||
await user.click(zoomOutButton);
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pending Appointments', () => {
|
||||
it('should display pending appointments in sidebar', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const pendingCount = screen.getByTestId('pending-count');
|
||||
expect(pendingCount).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter pending appointments from events', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not render pending appointment as event
|
||||
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible button labels', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
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: /new appointment/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have title attributes on navigation buttons', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo/Redo Controls', () => {
|
||||
it('should render undo and redo buttons', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Undo/redo buttons exist but are disabled
|
||||
const buttons = container.querySelectorAll('button[disabled]');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have undo and redo buttons disabled by default', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const disabledButtons = container.querySelectorAll('button[disabled]');
|
||||
expect(disabledButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors gracefully for resources', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Should still render even with error
|
||||
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully for appointments', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.resolve({ data: mockResources });
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty resources array', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const resourceCount = screen.getByTestId('resource-count');
|
||||
expect(resourceCount).toHaveTextContent('0');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty appointments array', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/resources/') {
|
||||
return Promise.resolve({ data: mockResources });
|
||||
}
|
||||
if (url === '/appointments/') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown endpoint'));
|
||||
});
|
||||
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const pendingCount = screen.getByTestId('pending-count');
|
||||
expect(pendingCount).toHaveTextContent('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should apply dark mode classes', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const mainContainer = container.querySelector('.bg-white');
|
||||
expect(mainContainer).toHaveClass('dark:bg-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply dark mode to header', async () => {
|
||||
const { container } = render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
const header = container.querySelector('.border-b');
|
||||
expect(header).toHaveClass('dark:bg-gray-800');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete timeline with all features', async () => {
|
||||
render(<Timeline />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
// Header controls
|
||||
expect(screen.getByTitle('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Next')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /day/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Zoom')).toBeInTheDocument();
|
||||
expect(screen.getByText('+ New Appointment')).toBeInTheDocument();
|
||||
|
||||
// Sidebar
|
||||
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
|
||||
|
||||
// Current time indicator
|
||||
expect(screen.getByTestId('current-time-indicator')).toBeInTheDocument();
|
||||
|
||||
// Resources
|
||||
expect(screen.getByTestId('resource-count')).toHaveTextContent('3');
|
||||
|
||||
// Events
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './ServiceList.css';
|
||||
|
||||
const ServiceList = ({ services, onSelectService, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return <div className="service-list-loading">Loading services...</div>;
|
||||
return <div className="service-list-loading">{t('services.loadingServices')}</div>;
|
||||
}
|
||||
|
||||
if (!services || services.length === 0) {
|
||||
return <div className="service-list-empty">No services available</div>;
|
||||
return <div className="service-list-empty">{t('services.noServicesAvailable')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-list">
|
||||
<h2>Available Services</h2>
|
||||
<h2>{t('services.availableServices')}</h2>
|
||||
<div className="service-grid">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
@@ -28,7 +30,7 @@ const ServiceList = ({ services, onSelectService, loading }) => {
|
||||
{service.description && (
|
||||
<p className="service-description">{service.description}</p>
|
||||
)}
|
||||
<button className="service-book-btn">Book Now</button>
|
||||
<button className="service-book-btn">{t('services.bookNow')}</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,14 @@ import {
|
||||
HelpCircle,
|
||||
Clock,
|
||||
Plug,
|
||||
FileSignature,
|
||||
CalendarOff,
|
||||
} 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,
|
||||
@@ -40,9 +43,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager' || role === 'staff';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager';
|
||||
const isStaff = role === 'staff';
|
||||
const canViewSettings = role === 'owner';
|
||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||
const canSendMessages = user.can_send_messages === true;
|
||||
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
@@ -59,7 +64,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-6 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
aria-label={isCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
|
||||
>
|
||||
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
@@ -108,18 +113,40 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
exact
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/tasks"
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/scheduler"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.scheduler')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!isStaff && (
|
||||
<SidebarItem
|
||||
to="/tasks"
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{isStaff && (
|
||||
<SidebarItem
|
||||
to="/my-schedule"
|
||||
icon={CalendarDays}
|
||||
label={t('nav.mySchedule', 'My Schedule')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
<SidebarItem
|
||||
to="/my-availability"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.myAvailability', 'My Availability')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Manage Section - Staff+ */}
|
||||
@@ -130,6 +157,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/services"
|
||||
@@ -144,20 +172,38 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
{canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{/* Communicate Section - Tickets + Messages */}
|
||||
{(canViewTickets || canViewAdminPages) && (
|
||||
{(canViewTickets || canSendMessages) && (
|
||||
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
||||
{canViewAdminPages && (
|
||||
{canSendMessages && (
|
||||
<SidebarItem
|
||||
to="/messages"
|
||||
icon={MessageSquare}
|
||||
@@ -193,11 +239,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
{canViewAdminPages && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/plugins/marketplace"
|
||||
to="/plugins/my-plugins"
|
||||
icon={Plug}
|
||||
label={t('nav.plugins', 'Plugins')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -233,7 +280,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
>
|
||||
<SmoothScheduleLogo className="w-5 h-5 text-white" />
|
||||
{!isCollapsed && (
|
||||
<span className="text-white/60">Smooth Schedule</span>
|
||||
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
|
||||
)}
|
||||
</a>
|
||||
<button
|
||||
|
||||
@@ -68,6 +68,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: false,
|
||||
roles: ['manager'],
|
||||
},
|
||||
{
|
||||
key: 'can_send_messages',
|
||||
labelKey: 'staff.canSendMessages',
|
||||
labelDefault: 'Can send broadcast messages',
|
||||
hintKey: 'staff.canSendMessagesHint',
|
||||
hintDefault: 'Send messages to groups of staff and customers',
|
||||
defaultValue: true,
|
||||
roles: ['manager'],
|
||||
},
|
||||
// Staff-only permissions
|
||||
{
|
||||
key: 'can_view_all_schedules',
|
||||
@@ -87,6 +96,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_self_approve_time_off',
|
||||
labelKey: 'staff.canSelfApproveTimeOff',
|
||||
labelDefault: 'Can self-approve time off',
|
||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Key,
|
||||
Eye,
|
||||
@@ -30,6 +31,7 @@ interface StripeApiKeysFormProps {
|
||||
}
|
||||
|
||||
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [publishableKey, setPublishableKey] = useState('');
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
@@ -72,7 +74,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
} catch (error: any) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: error.response?.data?.error || 'Validation failed',
|
||||
error: error.response?.data?.error || t('payments.stripeApiKeys.validationFailed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -87,7 +89,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
} catch (error: any) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
error: error.response?.data?.error || 'Failed to save keys',
|
||||
error: error.response?.data?.error || t('payments.stripeApiKeys.failedToSaveKeys'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -121,7 +123,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CheckCircle size={18} className="text-green-500" />
|
||||
Stripe Keys Configured
|
||||
{t('payments.stripeApiKeys.configured')}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Environment Badge */}
|
||||
@@ -136,12 +138,12 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{keyEnvironment === 'test' ? (
|
||||
<>
|
||||
<FlaskConical size={12} />
|
||||
Test Mode
|
||||
{t('payments.stripeApiKeys.testMode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={12} />
|
||||
Live Mode
|
||||
{t('payments.stripeApiKeys.liveMode')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -163,22 +165,22 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Publishable Key:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.publishableKey')}:</span>
|
||||
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Secret Key:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.secretKey')}:</span>
|
||||
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
|
||||
</div>
|
||||
{apiKeys.stripe_account_name && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Account:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.account')}:</span>
|
||||
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{apiKeys.last_validated_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Last Validated:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('payments.stripeApiKeys.lastValidated')}:</span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
|
||||
</span>
|
||||
@@ -190,10 +192,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
|
||||
<div className="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
||||
<FlaskConical size={16} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
You are using <strong>test keys</strong>. Payments will not be processed for real.
|
||||
Switch to live keys when ready to accept real payments.
|
||||
</span>
|
||||
<span dangerouslySetInnerHTML={{ __html: t('payments.stripeApiKeys.testKeysWarning') }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -214,14 +213,14 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Re-validate
|
||||
{t('payments.stripeApiKeys.revalidate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-700 bg-white border border-red-300 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Remove
|
||||
{t('payments.stripeApiKeys.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,10 +232,9 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800">API Keys Deprecated</h4>
|
||||
<h4 className="font-medium text-yellow-800">{t('payments.stripeApiKeys.deprecated')}</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Your API keys have been deprecated because you upgraded to a paid tier.
|
||||
Please complete Stripe Connect onboarding to accept payments.
|
||||
{t('payments.stripeApiKeys.deprecatedMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,19 +245,18 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{(!isConfigured || isDeprecated) && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{isConfigured ? 'Update API Keys' : 'Add Stripe API Keys'}
|
||||
{isConfigured ? t('payments.stripeApiKeys.updateApiKeys') : t('payments.stripeApiKeys.addApiKeys')}
|
||||
</h4>
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
Enter your Stripe API keys to enable payment collection.
|
||||
You can find these in your{' '}
|
||||
{t('payments.stripeApiKeys.enterKeysDescription')}{' '}
|
||||
<a
|
||||
href="https://dashboard.stripe.com/apikeys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Stripe Dashboard
|
||||
{t('payments.stripeApiKeys.stripeDashboard')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
@@ -267,7 +264,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{/* Publishable Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Publishable Key
|
||||
{t('payments.stripeApiKeys.publishableKeyLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key
|
||||
@@ -290,7 +287,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{/* Secret Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Secret Key
|
||||
{t('payments.stripeApiKeys.secretKeyLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key
|
||||
@@ -335,7 +332,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{validationResult.valid ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Keys are valid!</span>
|
||||
<span className="font-medium">{t('payments.stripeApiKeys.keysAreValid')}</span>
|
||||
{validationResult.environment && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
@@ -347,23 +344,23 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
{validationResult.environment === 'test' ? (
|
||||
<>
|
||||
<FlaskConical size={10} />
|
||||
Test Mode
|
||||
{t('payments.stripeApiKeys.testMode')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={10} />
|
||||
Live Mode
|
||||
{t('payments.stripeApiKeys.liveMode')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{validationResult.accountName && (
|
||||
<div>Connected to: {validationResult.accountName}</div>
|
||||
<div>{t('payments.stripeApiKeys.connectedTo', { accountName: validationResult.accountName })}</div>
|
||||
)}
|
||||
{validationResult.environment === 'test' && (
|
||||
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
|
||||
These are test keys. No real payments will be processed.
|
||||
{t('payments.stripeApiKeys.testKeysNote')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,7 +383,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
) : (
|
||||
<CheckCircle size={16} />
|
||||
)}
|
||||
Validate
|
||||
{t('payments.stripeApiKeys.validate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -398,7 +395,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
) : (
|
||||
<Key size={16} />
|
||||
)}
|
||||
Save Keys
|
||||
{t('payments.stripeApiKeys.saveKeys')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,18 +406,17 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Remove API Keys?
|
||||
{t('payments.stripeApiKeys.removeApiKeys')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Are you sure you want to remove your Stripe API keys?
|
||||
You will not be able to accept payments until you add them again.
|
||||
{t('payments.stripeApiKeys.removeApiKeysMessage')}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
{t('payments.stripeApiKeys.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
@@ -428,7 +424,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||
Remove
|
||||
{t('payments.stripeApiKeys.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
CreditCard,
|
||||
@@ -37,6 +38,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
transactionId,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
|
||||
const refundMutation = useRefundTransaction();
|
||||
|
||||
@@ -62,11 +64,11 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
if (refundType === 'partial') {
|
||||
const amountCents = Math.round(parseFloat(refundAmount) * 100);
|
||||
if (isNaN(amountCents) || amountCents <= 0) {
|
||||
setRefundError('Please enter a valid refund amount');
|
||||
setRefundError(t('payments.enterValidRefundAmount'));
|
||||
return;
|
||||
}
|
||||
if (amountCents > transaction.refundable_amount) {
|
||||
setRefundError(`Amount exceeds refundable amount ($${(transaction.refundable_amount / 100).toFixed(2)})`);
|
||||
setRefundError(t('payments.amountExceedsRefundable', { max: (transaction.refundable_amount / 100).toFixed(2) }));
|
||||
return;
|
||||
}
|
||||
request.amount = amountCents;
|
||||
@@ -80,7 +82,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
setShowRefundForm(false);
|
||||
setRefundAmount('');
|
||||
} catch (err: any) {
|
||||
setRefundError(err.response?.data?.error || 'Failed to process refund');
|
||||
setRefundError(err.response?.data?.error || t('payments.failedToProcessRefund'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,7 +145,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
</p>
|
||||
{pm.exp_month && pm.exp_year && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Expires {pm.exp_month}/{pm.exp_year}
|
||||
{t('payments.expires')} {pm.exp_month}/{pm.exp_year}
|
||||
{pm.funding && ` (${pm.funding})`}
|
||||
</p>
|
||||
)}
|
||||
@@ -176,7 +178,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Transaction Details
|
||||
{t('payments.transactionDetails')}
|
||||
</h3>
|
||||
{transaction && (
|
||||
<p className="text-sm text-gray-500 font-mono">
|
||||
@@ -204,7 +206,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={18} />
|
||||
<p className="font-medium">Failed to load transaction details</p>
|
||||
<p className="font-medium">{t('payments.failedToLoadTransaction')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -228,7 +230,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
Issue Refund
|
||||
{t('payments.issueRefund')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -238,7 +240,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-red-800">
|
||||
<RefreshCcw size={18} />
|
||||
<h4 className="font-semibold">Issue Refund</h4>
|
||||
<h4 className="font-semibold">{t('payments.issueRefund')}</h4>
|
||||
</div>
|
||||
|
||||
{/* Refund Type */}
|
||||
@@ -252,7 +254,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Full refund (${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
{t('payments.fullRefundAmount', { amount: (transaction.refundable_amount / 100).toFixed(2) })}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@@ -263,7 +265,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
onChange={() => setRefundType('partial')}
|
||||
className="text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Partial refund</span>
|
||||
<span className="text-sm text-gray-700">{t('payments.partialRefund')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -271,7 +273,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
{refundType === 'partial' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Amount (max ${(transaction.refundable_amount / 100).toFixed(2)})
|
||||
{t('payments.refundAmountMax', { max: (transaction.refundable_amount / 100).toFixed(2) })}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
@@ -292,16 +294,16 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Refund Reason
|
||||
{t('payments.refundReason')}
|
||||
</label>
|
||||
<select
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
<option value="requested_by_customer">Requested by customer</option>
|
||||
<option value="duplicate">Duplicate charge</option>
|
||||
<option value="fraudulent">Fraudulent</option>
|
||||
<option value="requested_by_customer">{t('payments.requestedByCustomer')}</option>
|
||||
<option value="duplicate">{t('payments.duplicate')}</option>
|
||||
<option value="fraudulent">{t('payments.fraudulent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -322,12 +324,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
{refundMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Processing...
|
||||
{t('payments.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcw size={16} />
|
||||
Confirm Refund
|
||||
{t('payments.processRefund')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -340,7 +342,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
disabled={refundMutation.isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,7 +354,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<User size={16} />
|
||||
Customer
|
||||
{t('payments.customer')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
{transaction.customer_name && (
|
||||
@@ -378,27 +380,27 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<DollarSign size={16} />
|
||||
Amount Breakdown
|
||||
{t('payments.amountBreakdown')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Gross Amount</span>
|
||||
<span className="text-gray-600">{t('payments.grossAmount')}</span>
|
||||
<span className="font-medium">{transaction.amount_display}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Platform Fee</span>
|
||||
<span className="text-gray-600">{t('payments.platformFee')}</span>
|
||||
<span className="text-red-600">-{transaction.fee_display}</span>
|
||||
</div>
|
||||
{transaction.total_refunded > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Refunded</span>
|
||||
<span className="text-gray-600">{t('payments.refunded')}</span>
|
||||
<span className="text-orange-600">
|
||||
-${(transaction.total_refunded / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
|
||||
<span className="font-medium text-gray-900 dark:text-white">Net Amount</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{t('payments.netAmount')}</span>
|
||||
<span className="font-bold text-green-600">
|
||||
${(transaction.net_amount / 100).toFixed(2)}
|
||||
</span>
|
||||
@@ -412,7 +414,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<CreditCard size={16} />
|
||||
Payment Method
|
||||
{t('payments.paymentMethod')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
{getPaymentMethodDisplay()}
|
||||
@@ -425,7 +427,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Receipt size={16} />
|
||||
Description
|
||||
{t('payments.description')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300">{transaction.description}</p>
|
||||
@@ -438,7 +440,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<RefreshCcw size={16} />
|
||||
Refund History
|
||||
{t('payments.refundHistory')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{transaction.refunds.map((refund: RefundInfo) => (
|
||||
@@ -451,7 +453,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<p className="text-sm text-orange-600">
|
||||
{refund.reason
|
||||
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: 'No reason provided'}
|
||||
: t('payments.noReasonProvided')}
|
||||
</p>
|
||||
<p className="text-xs text-orange-500 mt-1">
|
||||
{formatRefundDate(refund.created)}
|
||||
@@ -482,12 +484,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Calendar size={16} />
|
||||
Timeline
|
||||
{t('payments.timeline')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Created</span>
|
||||
<span className="text-gray-600">{t('payments.created')}</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.created_at)}
|
||||
</span>
|
||||
@@ -495,7 +497,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
{transaction.updated_at !== transaction.created_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<span className="text-gray-600">Last Updated</span>
|
||||
<span className="text-gray-600">{t('payments.lastUpdated')}</span>
|
||||
<span className="ml-auto text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.updated_at)}
|
||||
</span>
|
||||
@@ -508,29 +510,29 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ArrowLeftRight size={16} />
|
||||
Technical Details
|
||||
{t('payments.technicalDetails')}
|
||||
</h4>
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Payment Intent</span>
|
||||
<span className="text-gray-500">{t('payments.paymentIntent')}</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_payment_intent_id}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.stripe_charge_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Charge ID</span>
|
||||
<span className="text-gray-500">{t('payments.chargeId')}</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{transaction.stripe_charge_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Transaction ID</span>
|
||||
<span className="text-gray-500">{t('payments.transactionId')}</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Currency</span>
|
||||
<span className="text-gray-500">{t('payments.currency')}</span>
|
||||
<span className="text-gray-700 dark:text-gray-300 uppercase">
|
||||
{transaction.currency}
|
||||
</span>
|
||||
|
||||
429
frontend/src/components/__tests__/ConfirmationModal.test.tsx
Normal file
429
frontend/src/components/__tests__/ConfirmationModal.test.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 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 { 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>);
|
||||
};
|
||||
|
||||
describe('ConfirmationModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with title and message', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with React node as message', () => {
|
||||
const messageNode = (
|
||||
<div>
|
||||
<p>First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} message={messageNode} />);
|
||||
|
||||
expect(screen.getByText('First paragraph')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second paragraph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
const { container } = renderWithI18n(
|
||||
<ConfirmationModal {...defaultProps} isOpen={false} />
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render default confirm and cancel buttons', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom button labels', () => {
|
||||
renderWithI18n(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
confirmText="Yes, delete it"
|
||||
cancelText="No, keep it"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render close button in header', () => {
|
||||
renderWithI18n(<ConfirmationModal {...defaultProps} />);
|
||||
|
||||
// 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 })
|
||||
);
|
||||
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
752
frontend/src/components/__tests__/EmailTemplateSelector.test.tsx
Normal file
752
frontend/src/components/__tests__/EmailTemplateSelector.test.tsx
Normal file
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* 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 { 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,
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: 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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
264
frontend/src/components/__tests__/HelpButton.test.tsx
Normal file
264
frontend/src/components/__tests__/HelpButton.test.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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 { 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,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('HelpButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
560
frontend/src/components/__tests__/LanguageSelector.test.tsx
Normal file
560
frontend/src/components/__tests__/LanguageSelector.test.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 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 LanguageSelector from '../LanguageSelector';
|
||||
|
||||
// Mock i18n
|
||||
const mockChangeLanguage = vi.fn();
|
||||
const mockCurrentLanguage = 'en';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
language: mockCurrentLanguage,
|
||||
changeLanguage: mockChangeLanguage,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock i18n module with supported languages
|
||||
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"
|
||||
/>
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
|
||||
expect(screen.getByText('English')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain dropdown functionality across re-renders', () => {
|
||||
const { rerender } = render(<LanguageSelector />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
rerender(<LanguageSelector className="updated" />);
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(<LanguageSelector variant="inline" />);
|
||||
|
||||
const englishButton = screen.getByRole('button', { name: /English/i });
|
||||
fireEvent.click(englishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
|
||||
});
|
||||
|
||||
// Even if clicking the current language, it still calls changeLanguage
|
||||
// This is expected behavior (idempotent)
|
||||
});
|
||||
});
|
||||
});
|
||||
534
frontend/src/components/__tests__/MasqueradeBanner.test.tsx
Normal file
534
frontend/src/components/__tests__/MasqueradeBanner.test.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import { describe, it, expect, vi, beforeEach } 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;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
714
frontend/src/components/__tests__/PlatformSidebar.test.tsx
Normal file
714
frontend/src/components/__tests__/PlatformSidebar.test.tsx
Normal file
@@ -0,0 +1,714 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import PlatformSidebar from '../PlatformSidebar';
|
||||
import { User } from '../../types';
|
||||
|
||||
// Mock the i18next module
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'nav.platformDashboard': 'Platform Dashboard',
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.businesses': 'Businesses',
|
||||
'nav.users': 'Users',
|
||||
'nav.support': 'Support',
|
||||
'nav.staff': 'Staff',
|
||||
'nav.platformSettings': 'Platform Settings',
|
||||
'nav.help': 'Help',
|
||||
'nav.apiDocs': 'API Docs',
|
||||
};
|
||||
return translations[key] || fallback || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the SmoothScheduleLogo component
|
||||
vi.mock('../SmoothScheduleLogo', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="smooth-schedule-logo" className={className}>Logo</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('PlatformSidebar', () => {
|
||||
const mockSuperuser: User = {
|
||||
id: '1',
|
||||
name: 'Super User',
|
||||
email: 'super@example.com',
|
||||
role: 'superuser',
|
||||
};
|
||||
|
||||
const mockPlatformManager: User = {
|
||||
id: '2',
|
||||
name: 'Platform Manager',
|
||||
email: 'manager@example.com',
|
||||
role: 'platform_manager',
|
||||
};
|
||||
|
||||
const mockPlatformSupport: User = {
|
||||
id: '3',
|
||||
name: 'Platform Support',
|
||||
email: 'support@example.com',
|
||||
role: 'platform_support',
|
||||
};
|
||||
|
||||
const mockToggleCollapse = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the sidebar with logo and user role', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
|
||||
expect(screen.getByText('superuser')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all navigation links for superuser', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Operations section
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Email Addresses')[0]).toBeInTheDocument();
|
||||
|
||||
// System section (superuser only)
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staff')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Settings')).toBeInTheDocument();
|
||||
|
||||
// Help section
|
||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Email Settings')[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('API Docs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides system section for platform manager', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformManager}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Operations section visible
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Businesses')).toBeInTheDocument();
|
||||
|
||||
// System section not visible
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Platform Settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides system section and dashboard for platform support', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformSupport}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Dashboard not visible for support
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
|
||||
// Operations section visible
|
||||
expect(screen.getByText('Businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
|
||||
// System section not visible
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays role with underscores replaced by spaces', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformManager}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('platform manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collapsed State', () => {
|
||||
it('hides text labels when collapsed', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={true}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Logo should be visible
|
||||
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||
|
||||
// Text should be hidden
|
||||
expect(screen.queryByText('Smooth Schedule')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('superuser')).not.toBeInTheDocument();
|
||||
|
||||
// Section headers should show abbreviated versions
|
||||
expect(screen.getByText('Ops')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sys')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows full section names when expanded', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Operations')).toBeInTheDocument();
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Ops')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Sys')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct width classes based on collapsed state', () => {
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('w-64');
|
||||
expect(sidebar).not.toHaveClass('w-20');
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={true}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(sidebar).toHaveClass('w-20');
|
||||
expect(sidebar).not.toHaveClass('w-64');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toggle Collapse Button', () => {
|
||||
it('calls toggleCollapse when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: /collapse sidebar/i });
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(mockToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has correct aria-label when collapsed', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={true}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct aria-label when expanded', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /collapse sidebar/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Link Highlighting', () => {
|
||||
it('highlights the active link based on current path', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/platform/businesses']}>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const businessesLink = screen.getByRole('link', { name: /businesses/i });
|
||||
const usersLink = screen.getByRole('link', { name: /^users$/i });
|
||||
|
||||
// Active link should have active classes
|
||||
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
|
||||
expect(businessesLink).not.toHaveClass('text-gray-400');
|
||||
|
||||
// Inactive link should have inactive classes
|
||||
expect(usersLink).toHaveClass('text-gray-400');
|
||||
expect(usersLink).not.toHaveClass('bg-gray-700');
|
||||
});
|
||||
|
||||
it('highlights dashboard link when on dashboard route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/platform/dashboard']}>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const dashboardLink = screen.getByRole('link', { name: /dashboard/i });
|
||||
expect(dashboardLink).toHaveClass('bg-gray-700', 'text-white');
|
||||
});
|
||||
|
||||
it('highlights link for nested routes', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/platform/businesses/123']}>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const businessesLink = screen.getByRole('link', { name: /businesses/i });
|
||||
expect(businessesLink).toHaveClass('bg-gray-700', 'text-white');
|
||||
});
|
||||
|
||||
it('highlights staff link when on staff route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/platform/staff']}>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const staffLink = screen.getByRole('link', { name: /staff/i });
|
||||
expect(staffLink).toHaveClass('bg-gray-700', 'text-white');
|
||||
});
|
||||
|
||||
it('highlights help link when on help route', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/help/api']}>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const apiDocsLink = screen.getByRole('link', { name: /api docs/i });
|
||||
expect(apiDocsLink).toHaveClass('bg-gray-700', 'text-white');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('has correct href attributes for all links', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '/platform/dashboard');
|
||||
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('href', '/platform/businesses');
|
||||
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('href', '/platform/users');
|
||||
expect(screen.getByRole('link', { name: /support/i })).toHaveAttribute('href', '/platform/support');
|
||||
expect(screen.getByRole('link', { name: /staff/i })).toHaveAttribute('href', '/platform/staff');
|
||||
expect(screen.getByRole('link', { name: /platform settings/i })).toHaveAttribute('href', '/platform/settings');
|
||||
expect(screen.getByRole('link', { name: /api docs/i })).toHaveAttribute('href', '/help/api');
|
||||
});
|
||||
|
||||
it('shows title attributes on links for accessibility', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('title', 'Platform Dashboard');
|
||||
expect(screen.getByRole('link', { name: /businesses/i })).toHaveAttribute('title', 'Businesses');
|
||||
expect(screen.getByRole('link', { name: /^users$/i })).toHaveAttribute('title', 'Users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icons', () => {
|
||||
it('renders lucide-react icons for all navigation items', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Check that SVG icons are present (lucide-react renders as SVG)
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
// Should have: logo + icons for each nav item
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('keeps icons visible when collapsed', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={true}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Icons should still be present when collapsed
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('applies flex column layout', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('flex', 'flex-col', 'h-full');
|
||||
});
|
||||
|
||||
it('applies dark theme colors', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('bg-gray-900', 'text-white');
|
||||
});
|
||||
|
||||
it('has transition classes for smooth collapse animation', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const sidebar = container.firstChild as HTMLElement;
|
||||
expect(sidebar).toHaveClass('transition-all', 'duration-300');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access Control', () => {
|
||||
it('shows dashboard for superuser and platform_manager only', () => {
|
||||
const { rerender } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformManager}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformSupport}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows system section only for superuser', () => {
|
||||
const { rerender } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('System')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Staff')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformManager}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Staff')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockPlatformSupport}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('always shows common operations links for all roles', () => {
|
||||
const roles: User[] = [mockSuperuser, mockPlatformManager, mockPlatformSupport];
|
||||
|
||||
roles.forEach((user) => {
|
||||
const { unmount } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={user}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Businesses')).toBeInTheDocument();
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support')).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has semantic HTML structure with nav element', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const nav = container.querySelector('nav');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides proper button label for keyboard users', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /collapse sidebar/i });
|
||||
expect(button).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('all links have accessible names', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
links.forEach((link) => {
|
||||
expect(link).toHaveAccessibleName();
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains focus visibility for keyboard navigation', () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /collapse sidebar/i });
|
||||
expect(button).toHaveClass('focus:outline-none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles user with empty name gracefully', () => {
|
||||
const userWithoutName: User = {
|
||||
...mockSuperuser,
|
||||
name: '',
|
||||
};
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={userWithoutName}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should still render without crashing
|
||||
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles missing translation gracefully', () => {
|
||||
// Translation mock should return the key if translation is missing
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should render without errors even with missing translations
|
||||
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles rapid collapse/expand toggling', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<PlatformSidebar
|
||||
user={mockSuperuser}
|
||||
isCollapsed={false}
|
||||
toggleCollapse={mockToggleCollapse}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /collapse sidebar/i });
|
||||
|
||||
// Rapidly click multiple times
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
expect(mockToggleCollapse).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
453
frontend/src/components/__tests__/Portal.test.tsx
Normal file
453
frontend/src/components/__tests__/Portal.test.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Unit tests for Portal component
|
||||
*
|
||||
* Tests the Portal component which uses ReactDOM.createPortal to render
|
||||
* children outside the parent DOM hierarchy. This is useful for modals,
|
||||
* tooltips, and other UI elements that need to escape parent stacking contexts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import Portal from '../Portal';
|
||||
|
||||
describe('Portal', () => {
|
||||
afterEach(() => {
|
||||
// Clean up any rendered components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Portal Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Portal Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render text content', () => {
|
||||
render(<Portal>Simple text content</Portal>);
|
||||
|
||||
expect(screen.getByText('Simple text content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render complex JSX children', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<div>
|
||||
<h1>Title</h1>
|
||||
<p>Description</p>
|
||||
<button>Click me</button>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Portal Behavior', () => {
|
||||
it('should render content to document.body', () => {
|
||||
const { container } = render(
|
||||
<div id="root">
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Portal Content</div>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const portalContent = screen.getByTestId('portal-content');
|
||||
|
||||
// Portal content should NOT be inside the container
|
||||
expect(container.contains(portalContent)).toBe(false);
|
||||
|
||||
// Portal content SHOULD be inside document.body
|
||||
expect(document.body.contains(portalContent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should escape parent DOM hierarchy', () => {
|
||||
const { container } = render(
|
||||
<div id="parent" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div id="child">
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Escaped Content</div>
|
||||
</Portal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const portalContent = screen.getByTestId('portal-content');
|
||||
const parent = container.querySelector('#parent');
|
||||
|
||||
// Portal content should not be inside parent
|
||||
expect(parent?.contains(portalContent)).toBe(false);
|
||||
|
||||
// Portal content should be direct child of body
|
||||
expect(portalContent.parentElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Children', () => {
|
||||
it('should render multiple children', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<div data-testid="child-1">First child</div>
|
||||
<div data-testid="child-2">Second child</div>
|
||||
<div data-testid="child-3">Third child</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('child-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an array of children', () => {
|
||||
const items = ['Item 1', 'Item 2', 'Item 3'];
|
||||
|
||||
render(
|
||||
<Portal>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} data-testid={`item-${index}`}>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
expect(screen.getByTestId(`item-${index}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(item)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render nested components', () => {
|
||||
const NestedComponent = () => (
|
||||
<div data-testid="nested">
|
||||
<span>Nested Component</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
<Portal>
|
||||
<NestedComponent />
|
||||
<div>Other content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('nested')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nested Component')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mounting Behavior', () => {
|
||||
it('should not render before component is mounted', () => {
|
||||
// This test verifies the internal mounting state
|
||||
const { rerender } = render(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
// After initial render, content should be present
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
||||
|
||||
// Re-render should still show content
|
||||
rerender(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Updated Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Portals', () => {
|
||||
it('should support multiple portal instances', () => {
|
||||
render(
|
||||
<div>
|
||||
<Portal>
|
||||
<div data-testid="portal-1">Portal 1</div>
|
||||
</Portal>
|
||||
<Portal>
|
||||
<div data-testid="portal-2">Portal 2</div>
|
||||
</Portal>
|
||||
<Portal>
|
||||
<div data-testid="portal-3">Portal 3</div>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('portal-3')).toBeInTheDocument();
|
||||
|
||||
// All portals should be in document.body
|
||||
expect(document.body.contains(screen.getByTestId('portal-1'))).toBe(true);
|
||||
expect(document.body.contains(screen.getByTestId('portal-2'))).toBe(true);
|
||||
expect(document.body.contains(screen.getByTestId('portal-3'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep portals separate from each other', () => {
|
||||
render(
|
||||
<div>
|
||||
<Portal>
|
||||
<div data-testid="portal-1">
|
||||
<span data-testid="content-1">Content 1</span>
|
||||
</div>
|
||||
</Portal>
|
||||
<Portal>
|
||||
<div data-testid="portal-2">
|
||||
<span data-testid="content-2">Content 2</span>
|
||||
</div>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const portal1 = screen.getByTestId('portal-1');
|
||||
const portal2 = screen.getByTestId('portal-2');
|
||||
const content1 = screen.getByTestId('content-1');
|
||||
const content2 = screen.getByTestId('content-2');
|
||||
|
||||
// Each portal should contain only its own content
|
||||
expect(portal1.contains(content1)).toBe(true);
|
||||
expect(portal1.contains(content2)).toBe(false);
|
||||
expect(portal2.contains(content2)).toBe(true);
|
||||
expect(portal2.contains(content1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should remove content from body when unmounted', () => {
|
||||
const { unmount } = render(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Temporary Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
// Content should exist initially
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
||||
|
||||
// Unmount the component
|
||||
unmount();
|
||||
|
||||
// Content should be removed from DOM
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clean up multiple portals on unmount', () => {
|
||||
const { unmount } = render(
|
||||
<div>
|
||||
<Portal>
|
||||
<div data-testid="portal-1">Portal 1</div>
|
||||
</Portal>
|
||||
<Portal>
|
||||
<div data-testid="portal-2">Portal 2</div>
|
||||
</Portal>
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(screen.queryByTestId('portal-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('portal-2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Re-rendering', () => {
|
||||
it('should update content on re-render', () => {
|
||||
const { rerender } = render(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Initial Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Initial Content')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Portal>
|
||||
<div data-testid="portal-content">Updated Content</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Initial Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle prop changes', () => {
|
||||
const TestComponent = ({ message }: { message: string }) => (
|
||||
<Portal>
|
||||
<div data-testid="message">{message}</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
const { rerender } = render(<TestComponent message="First message" />);
|
||||
|
||||
expect(screen.getByText('First message')).toBeInTheDocument();
|
||||
|
||||
rerender(<TestComponent message="Second message" />);
|
||||
|
||||
expect(screen.getByText('Second message')).toBeInTheDocument();
|
||||
expect(screen.queryByText('First message')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty children', () => {
|
||||
render(<Portal>{null}</Portal>);
|
||||
|
||||
// Should not throw error
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined children', () => {
|
||||
render(<Portal>{undefined}</Portal>);
|
||||
|
||||
// Should not throw error
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle boolean children', () => {
|
||||
render(
|
||||
<Portal>
|
||||
{false && <div>Should not render</div>}
|
||||
{true && <div data-testid="should-render">Should render</div>}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('should-render')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle conditional rendering', () => {
|
||||
const { rerender } = render(
|
||||
<Portal>
|
||||
{false && <div data-testid="conditional">Conditional Content</div>}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('conditional')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Portal>
|
||||
{true && <div data-testid="conditional">Conditional Content</div>}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('conditional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Parent Components', () => {
|
||||
it('should work inside modals', () => {
|
||||
const Modal = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="modal" data-testid="modal">
|
||||
<Portal>{children}</Portal>
|
||||
</div>
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<Modal>
|
||||
<div data-testid="modal-content">Modal Content</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const modalContent = screen.getByTestId('modal-content');
|
||||
const modal = container.querySelector('[data-testid="modal"]');
|
||||
|
||||
// Modal content should not be inside modal container
|
||||
expect(modal?.contains(modalContent)).toBe(false);
|
||||
|
||||
// Modal content should be in document.body
|
||||
expect(document.body.contains(modalContent)).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve event handlers', () => {
|
||||
let clicked = false;
|
||||
const handleClick = () => {
|
||||
clicked = true;
|
||||
};
|
||||
|
||||
render(
|
||||
<Portal>
|
||||
<button data-testid="button" onClick={handleClick}>
|
||||
Click me
|
||||
</button>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('button');
|
||||
button.click();
|
||||
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve CSS classes and styles', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<div
|
||||
data-testid="styled-content"
|
||||
className="custom-class"
|
||||
style={{ color: 'red', fontSize: '16px' }}
|
||||
>
|
||||
Styled Content
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
const styledContent = screen.getByTestId('styled-content');
|
||||
|
||||
expect(styledContent).toHaveClass('custom-class');
|
||||
// Check styles individually - color may be normalized to rgb()
|
||||
expect(styledContent.style.color).toBeTruthy();
|
||||
expect(styledContent.style.fontSize).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should maintain ARIA attributes', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<div
|
||||
data-testid="aria-content"
|
||||
role="dialog"
|
||||
aria-label="Test Dialog"
|
||||
aria-describedby="description"
|
||||
>
|
||||
<div id="description">Dialog description</div>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('aria-content');
|
||||
|
||||
expect(content).toHaveAttribute('role', 'dialog');
|
||||
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
|
||||
expect(content).toHaveAttribute('aria-describedby', 'description');
|
||||
});
|
||||
|
||||
it('should support semantic HTML inside portal', () => {
|
||||
render(
|
||||
<Portal>
|
||||
<dialog open data-testid="dialog">
|
||||
<h2>Dialog Title</h2>
|
||||
<p>Dialog content</p>
|
||||
</dialog>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Dialog Title' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
681
frontend/src/components/__tests__/QuotaWarningBanner.test.tsx
Normal file
681
frontend/src/components/__tests__/QuotaWarningBanner.test.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* Unit tests for QuotaWarningBanner component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Rendering based on quota overage state
|
||||
* - Critical, urgent, and warning severity levels
|
||||
* - Display of correct percentage and usage information
|
||||
* - Multiple overages display
|
||||
* - Manage Quota button/link functionality
|
||||
* - Dismiss button functionality
|
||||
* - Date formatting
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility attributes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import QuotaWarningBanner from '../QuotaWarningBanner';
|
||||
import { QuotaOverage } from '../../api/auth';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback: string, options?: Record<string, unknown>) => {
|
||||
// Handle interpolation for dynamic values
|
||||
if (options) {
|
||||
let result = fallback;
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
result = result.replace(`{{${key}}}`, String(value));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return fallback;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
// Test data factories
|
||||
const createMockOverage = (overrides?: Partial<QuotaOverage>): QuotaOverage => ({
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 14,
|
||||
grace_period_ends_at: '2025-12-21T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('QuotaWarningBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering Conditions', () => {
|
||||
it('should not render when overages array is empty', () => {
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={[]} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when overages is null', () => {
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={null as any} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should not render when overages is undefined', () => {
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={undefined as any} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render when quota is near limit (warning state)', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 14 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/quota exceeded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render when quota is critical (1 day remaining)', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 1 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render when quota is urgent (7 days remaining)', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 7 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Severity Levels and Styling', () => {
|
||||
it('should apply warning styles for normal overages (>7 days)', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 14 })];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('div[class*="bg-amber-100"]');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply urgent styles for 7 days or less', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 7 })];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('div[class*="bg-amber-500"]');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply critical styles for 1 day or less', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 1 })];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('div[class*="bg-red-600"]');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply critical styles for 0 days remaining', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 0 })];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('div[class*="bg-red-600"]');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage and Percentage Display', () => {
|
||||
it('should display correct overage amount', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
overage_amount: 5,
|
||||
display_name: 'Resources',
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current usage and limit in multi-overage list', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
id: 1,
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
display_name: 'Staff Members',
|
||||
}),
|
||||
createMockOverage({
|
||||
id: 2,
|
||||
current_usage: 20,
|
||||
allowed_limit: 15,
|
||||
display_name: 'Resources',
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Usage/limit is shown in the "All overages" list when there are multiple
|
||||
expect(screen.getByText(/Staff Members: 15\/10/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Resources: 20\/15/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display quota type name', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
display_name: 'Calendar Events',
|
||||
overage_amount: 100,
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/you have 100 Calendar Events over your plan limit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format and display grace period end date', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
grace_period_ends_at: '2025-12-25T00:00:00Z',
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Date formatting will depend on locale, but should contain the date components
|
||||
const detailsText = screen.getByText(/grace period ends/i);
|
||||
expect(detailsText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Overages', () => {
|
||||
it('should display most urgent overage in main message', () => {
|
||||
const overages = [
|
||||
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources' }),
|
||||
createMockOverage({ id: 2, days_remaining: 3, display_name: 'Staff Members' }),
|
||||
createMockOverage({ id: 3, days_remaining: 7, display_name: 'Events' }),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Should show the most urgent (3 days)
|
||||
expect(screen.getByText(/action required.*3 days left/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show additional overages section when multiple overages exist', () => {
|
||||
const overages = [
|
||||
createMockOverage({ id: 1, days_remaining: 14, display_name: 'Resources', current_usage: 15, allowed_limit: 10, overage_amount: 5 }),
|
||||
createMockOverage({ id: 2, days_remaining: 7, display_name: 'Staff', current_usage: 8, allowed_limit: 5, overage_amount: 3 }),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should list all overages with details in the additional section', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
id: 1,
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 14,
|
||||
}),
|
||||
createMockOverage({
|
||||
id: 2,
|
||||
display_name: 'Staff',
|
||||
current_usage: 8,
|
||||
allowed_limit: 5,
|
||||
overage_amount: 3,
|
||||
days_remaining: 7,
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/over by 5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Staff: 8\/5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/over by 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show additional overages section for single overage', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/all overages:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "expires today" for 0 days remaining in overage list', () => {
|
||||
const overages = [
|
||||
createMockOverage({ id: 1, days_remaining: 14 }),
|
||||
createMockOverage({ id: 2, days_remaining: 0, display_name: 'Critical Item' }),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/expires today!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manage Quota Button', () => {
|
||||
it('should render Manage Quota link', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should link to settings/quota page', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toHaveAttribute('href', '/settings/quota');
|
||||
});
|
||||
|
||||
it('should display external link icon', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
const icon = link.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply warning button styles for normal overages', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 14 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toHaveClass('bg-amber-600');
|
||||
});
|
||||
|
||||
it('should apply urgent button styles for urgent/critical overages', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 7 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toHaveClass('bg-white/20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dismiss Button', () => {
|
||||
it('should render dismiss button when onDismiss prop is provided', () => {
|
||||
const overages = [createMockOverage()];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render dismiss button when onDismiss prop is not provided', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const dismissButton = screen.queryByRole('button', { name: /dismiss/i });
|
||||
expect(dismissButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onDismiss when dismiss button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const overages = [createMockOverage()];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should display X icon in dismiss button', () => {
|
||||
const overages = [createMockOverage()];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
const icon = dismissButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have alert icon with appropriate styling', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// AlertTriangle icon should be present
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible label for dismiss button', () => {
|
||||
const overages = [createMockOverage()];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
|
||||
});
|
||||
|
||||
it('should use semantic HTML structure', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 14 })];
|
||||
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Should have proper div structure
|
||||
expect(container.querySelector('div')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible link for Manage Quota', () => {
|
||||
const overages = [createMockOverage()];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.tagName).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Priority', () => {
|
||||
it('should show critical message for 1 day remaining', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 1 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/urgent.*automatic archiving tomorrow/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show urgent message for 2-7 days remaining', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 5 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/action required.*5 days left/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show warning message for more than 7 days remaining', () => {
|
||||
const overages = [createMockOverage({ days_remaining: 10 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/quota exceeded for 1 item/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show count of overages in warning message', () => {
|
||||
const overages = [
|
||||
createMockOverage({ id: 1, days_remaining: 14 }),
|
||||
createMockOverage({ id: 2, days_remaining: 10 }),
|
||||
createMockOverage({ id: 3, days_remaining: 12 }),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/quota exceeded for 3 item/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete banner with all elements', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
id: 1,
|
||||
quota_type: 'resources',
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 7,
|
||||
grace_period_ends_at: '2025-12-21T00:00:00Z',
|
||||
}),
|
||||
];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Check main message
|
||||
expect(screen.getByText(/action required.*7 days left/i)).toBeInTheDocument();
|
||||
|
||||
// Check details
|
||||
expect(screen.getByText(/you have 5 Resources over your plan limit/i)).toBeInTheDocument();
|
||||
|
||||
// Check Manage Quota link
|
||||
const link = screen.getByRole('link', { name: /manage quota/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/settings/quota');
|
||||
|
||||
// Check dismiss button
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
|
||||
// Check icons are present (via SVG elements)
|
||||
const { container } = render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const icons = container.querySelectorAll('svg');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle complex multi-overage scenario', async () => {
|
||||
const user = userEvent.setup();
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
id: 1,
|
||||
display_name: 'Resources',
|
||||
current_usage: 15,
|
||||
allowed_limit: 10,
|
||||
overage_amount: 5,
|
||||
days_remaining: 14,
|
||||
}),
|
||||
createMockOverage({
|
||||
id: 2,
|
||||
display_name: 'Staff Members',
|
||||
current_usage: 12,
|
||||
allowed_limit: 8,
|
||||
overage_amount: 4,
|
||||
days_remaining: 2,
|
||||
}),
|
||||
createMockOverage({
|
||||
id: 3,
|
||||
display_name: 'Calendar Events',
|
||||
current_usage: 500,
|
||||
allowed_limit: 400,
|
||||
overage_amount: 100,
|
||||
days_remaining: 7,
|
||||
}),
|
||||
];
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} onDismiss={onDismiss} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Should show most urgent (2 days)
|
||||
expect(screen.getByText(/action required.*2 days left/i)).toBeInTheDocument();
|
||||
|
||||
// Should show all overages section
|
||||
expect(screen.getByText(/all overages:/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Resources: 15\/10/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Staff Members: 12\/8/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Calendar Events: 500\/400/)).toBeInTheDocument();
|
||||
|
||||
// Should be able to dismiss
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
await user.click(dismissButton);
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle negative days remaining', () => {
|
||||
const overages = [createMockOverage({ days_remaining: -1 })];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Should treat as critical (0 or less)
|
||||
const { container } = render(
|
||||
<QuotaWarningBanner overages={overages} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const banner = container.querySelector('div[class*="bg-red-600"]');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very large overage amounts', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
overage_amount: 999999,
|
||||
display_name: 'Events',
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/you have 999999 Events over your plan limit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle zero overage amount', () => {
|
||||
const overages = [
|
||||
createMockOverage({
|
||||
overage_amount: 0,
|
||||
current_usage: 10,
|
||||
allowed_limit: 10,
|
||||
}),
|
||||
];
|
||||
|
||||
render(<QuotaWarningBanner overages={overages} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText(/you have 0 Resources over your plan limit/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
511
frontend/src/components/__tests__/TrialBanner.test.tsx
Normal file
511
frontend/src/components/__tests__/TrialBanner.test.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Unit tests for TrialBanner component
|
||||
*
|
||||
* Tests the trial status banner that appears at the top of the business layout.
|
||||
* Covers:
|
||||
* - Rendering with different days remaining
|
||||
* - Urgent state (3 days or less)
|
||||
* - Upgrade button navigation
|
||||
* - Dismiss functionality
|
||||
* - Hidden states (dismissed, not active, no days left)
|
||||
* - Trial end date formatting
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TrialBanner from '../TrialBanner';
|
||||
import { Business } from '../../types';
|
||||
|
||||
// Mock react-router-dom's useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
// Simulate translation behavior
|
||||
const translations: Record<string, string> = {
|
||||
'trial.banner.title': 'Trial Active',
|
||||
'trial.banner.daysLeft': `${params?.days} days left in trial`,
|
||||
'trial.banner.expiresOn': `Trial expires on ${params?.date}`,
|
||||
'trial.banner.upgradeNow': 'Upgrade Now',
|
||||
'trial.banner.dismiss': 'Dismiss',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test data factory for Business objects
|
||||
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||
id: '1',
|
||||
name: 'Test Business',
|
||||
subdomain: 'testbiz',
|
||||
primaryColor: '#3B82F6',
|
||||
secondaryColor: '#1E40AF',
|
||||
whitelabelEnabled: false,
|
||||
paymentsEnabled: true,
|
||||
requirePaymentMethodToBook: false,
|
||||
cancellationWindowHours: 24,
|
||||
lateCancellationFeePercent: 50,
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
trialEnd: '2025-12-17T23:59:59Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Wrapper component that provides router context
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('TrialBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render banner with trial information when trial is active', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
trialEnd: '2025-12-17T23:59:59Z',
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10 days left in trial/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /upgrade now/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the trial end date', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5,
|
||||
trialEnd: '2025-12-17T00:00:00Z',
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Check that the date is displayed (format may vary by locale)
|
||||
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Sparkles icon when more than 3 days left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 7,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// The Sparkles icon should be rendered (not the Clock icon)
|
||||
// Check for the non-urgent styling
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Clock icon with pulse animation when 3 days or less left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 3,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Check for urgent styling
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
|
||||
// Check for pulse animation on the icon
|
||||
const pulsingIcon = container.querySelector('.animate-pulse');
|
||||
expect(pulsingIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Upgrade Now button with arrow icon', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).toHaveClass('bg-white', 'text-blue-600');
|
||||
});
|
||||
|
||||
it('should render dismiss button with aria-label', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Urgent State (3 days or less)', () => {
|
||||
it('should apply urgent styling when 3 days left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 3,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply urgent styling when 2 days left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 2,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply urgent styling when 1 day left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 1,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/1 days left in trial/i)).toBeInTheDocument();
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT apply urgent styling when 4 days left', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 4,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600.to-blue-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to /upgrade when Upgrade Now button is clicked', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
fireEvent.click(upgradeButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/upgrade');
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should hide banner when dismiss button is clicked', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Banner should be visible initially
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
|
||||
// Click dismiss button
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Banner should be hidden
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should keep banner hidden after dismissing even when multiple clicks', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Banner should remain hidden
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hidden States', () => {
|
||||
it('should not render when trial is not active', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: false,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when daysLeftInTrial is undefined', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: undefined,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when daysLeftInTrial is 0', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 0,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when daysLeftInTrial is null', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: null as unknown as number,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when already dismissed', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Dismiss the banner
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
// Banner should not be visible
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing trialEnd date gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5,
|
||||
trialEnd: undefined,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Banner should still render
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle invalid trialEnd date gracefully', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 5,
|
||||
trialEnd: 'invalid-date',
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Banner should still render despite invalid date
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct styling for boundary case of exactly 3 days', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 3,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Should use urgent styling at exactly 3 days
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very large number of days remaining', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 999,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/999 days left in trial/i)).toBeInTheDocument();
|
||||
// Should use non-urgent styling
|
||||
const { container } = render(<TrialBanner business={business} />, { wrapper: BrowserRouter });
|
||||
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
|
||||
expect(banner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper button roles and labels', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(dismissButton).toBeInTheDocument();
|
||||
expect(dismissButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have readable text content for screen readers', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 7,
|
||||
trialEnd: '2025-12-24T23:59:59Z',
|
||||
});
|
||||
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// All important text should be accessible
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/7 days left in trial/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Behavior', () => {
|
||||
it('should render trial end date with hidden class for small screens', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
trialEnd: '2025-12-17T23:59:59Z',
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// The trial end date paragraph should have 'hidden sm:block' classes
|
||||
const endDateElement = container.querySelector('.hidden.sm\\:block');
|
||||
expect(endDateElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all key elements in the banner', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Icon container
|
||||
const iconContainer = container.querySelector('.p-2.rounded-full');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
|
||||
// Buttons container
|
||||
const buttonsContainer = screen.getByRole('button', { name: /upgrade now/i }).parentElement;
|
||||
expect(buttonsContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('should work with different business configurations', () => {
|
||||
const businesses = [
|
||||
createMockBusiness({ daysLeftInTrial: 1, isTrialActive: true }),
|
||||
createMockBusiness({ daysLeftInTrial: 7, isTrialActive: true }),
|
||||
createMockBusiness({ daysLeftInTrial: 14, isTrialActive: true }),
|
||||
];
|
||||
|
||||
businesses.forEach((business) => {
|
||||
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain state across re-renders when not dismissed', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
|
||||
// Re-render with updated days
|
||||
const updatedBusiness = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 9,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<TrialBanner business={updatedBusiness} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/9 days left in trial/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset dismissed state on component unmount and remount', () => {
|
||||
const business = createMockBusiness({
|
||||
isTrialActive: true,
|
||||
daysLeftInTrial: 10,
|
||||
});
|
||||
|
||||
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Dismiss the banner
|
||||
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
||||
|
||||
// Unmount and remount
|
||||
unmount();
|
||||
renderWithRouter(<TrialBanner business={business} />);
|
||||
|
||||
// Banner should reappear (dismissed state is not persisted)
|
||||
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
66
frontend/src/components/booking/BookingWidget.tsx
Normal file
66
frontend/src/components/booking/BookingWidget.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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 rounded-lg shadow-md max-w-md mx-auto text-left">
|
||||
<h2 className="text-2xl font-bold mb-2" style={{ color: accentColor }}>{headline}</h2>
|
||||
<p className="text-gray-600 mb-6">{subheading}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{services?.length === 0 && <p>No services available.</p>}
|
||||
{services?.map((service: any) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className={`p-4 border rounded cursor-pointer transition-colors ${selectedService?.id === service.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-300'}`}
|
||||
onClick={() => setSelectedService(service)}
|
||||
>
|
||||
<h3 className="font-semibold">{service.name}</h3>
|
||||
<p className="text-sm text-gray-500">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleBook}
|
||||
disabled={!selectedService}
|
||||
className="w-full py-2 px-4 rounded text-white font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingWidget;
|
||||
142
frontend/src/components/dashboard/CapacityWidget.tsx
Normal file
142
frontend/src/components/dashboard/CapacityWidget.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, X, Users, User } from 'lucide-react';
|
||||
import { Appointment, Resource } from '../../types';
|
||||
import { startOfWeek, endOfWeek, isWithinInterval } from 'date-fns';
|
||||
|
||||
interface CapacityWidgetProps {
|
||||
appointments: Appointment[];
|
||||
resources: Resource[];
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const CapacityWidget: React.FC<CapacityWidgetProps> = ({
|
||||
appointments,
|
||||
resources,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const capacityData = useMemo(() => {
|
||||
const now = new Date();
|
||||
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
|
||||
|
||||
// Calculate for each resource
|
||||
const resourceStats = resources.map((resource) => {
|
||||
// Filter appointments for this resource this week
|
||||
const resourceAppointments = appointments.filter(
|
||||
(appt) =>
|
||||
appt.resourceId === resource.id &&
|
||||
isWithinInterval(new Date(appt.startTime), { start: weekStart, end: weekEnd }) &&
|
||||
appt.status !== 'CANCELLED'
|
||||
);
|
||||
|
||||
// Calculate total booked minutes
|
||||
const bookedMinutes = resourceAppointments.reduce(
|
||||
(sum, appt) => sum + appt.durationMinutes,
|
||||
0
|
||||
);
|
||||
|
||||
// Assume 8 hours/day, 5 days/week = 2400 minutes capacity
|
||||
const totalCapacityMinutes = 8 * 60 * 5;
|
||||
const utilization = Math.min((bookedMinutes / totalCapacityMinutes) * 100, 100);
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
utilization: Math.round(utilization),
|
||||
bookedHours: Math.round(bookedMinutes / 60),
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate overall utilization
|
||||
const totalBooked = resourceStats.reduce((sum, r) => sum + r.bookedHours, 0);
|
||||
const totalCapacity = resources.length * 40; // 40 hours/week per resource
|
||||
const overallUtilization = totalCapacity > 0 ? Math.round((totalBooked / totalCapacity) * 100) : 0;
|
||||
|
||||
return {
|
||||
overall: overallUtilization,
|
||||
resources: resourceStats.sort((a, b) => b.utilization - a.utilization),
|
||||
};
|
||||
}, [appointments, resources]);
|
||||
|
||||
const getUtilizationColor = (utilization: number) => {
|
||||
if (utilization >= 80) return 'bg-green-500';
|
||||
if (utilization >= 50) return 'bg-yellow-500';
|
||||
return 'bg-gray-300 dark:bg-gray-600';
|
||||
};
|
||||
|
||||
const getUtilizationTextColor = (utilization: number) => {
|
||||
if (utilization >= 80) return 'text-green-600 dark:text-green-400';
|
||||
if (utilization >= 50) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center justify-between mb-3 ${isEditing ? 'pl-5' : ''}`}>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Capacity This Week
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{capacityData.overall}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{capacityData.resources.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<Users size={32} className="mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('dashboard.noResourcesConfigured')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">
|
||||
{capacityData.resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<User size={12} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-300 truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${getUtilizationColor(resource.utilization)} transition-all duration-300`}
|
||||
style={{ width: `${resource.utilization}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${getUtilizationTextColor(resource.utilization)}`}>
|
||||
{resource.utilization}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapacityWidget;
|
||||
105
frontend/src/components/dashboard/ChartWidget.tsx
Normal file
105
frontend/src/components/dashboard/ChartWidget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
|
||||
interface ChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ChartWidgetProps {
|
||||
title: string;
|
||||
data: ChartData[];
|
||||
type: 'bar' | 'line';
|
||||
color?: string;
|
||||
valuePrefix?: string;
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
title,
|
||||
data,
|
||||
type,
|
||||
color = '#3b82f6',
|
||||
valuePrefix = '',
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const formatValue = (value: number) => `${valuePrefix}${value}`;
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tickFormatter={formatValue} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value: number) => [formatValue(value), title]}
|
||||
/>
|
||||
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value: number) => [value, title]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 4, fill: color }} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartWidget;
|
||||
136
frontend/src/components/dashboard/CustomerBreakdownWidget.tsx
Normal file
136
frontend/src/components/dashboard/CustomerBreakdownWidget.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Customer } from '../../types';
|
||||
|
||||
interface CustomerBreakdownWidgetProps {
|
||||
customers: Customer[];
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
||||
customers,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const breakdownData = useMemo(() => {
|
||||
// Customers with lastVisit are returning, without are new
|
||||
const returning = customers.filter((c) => c.lastVisit !== null).length;
|
||||
const newCustomers = customers.filter((c) => c.lastVisit === null).length;
|
||||
const total = customers.length;
|
||||
|
||||
return {
|
||||
new: newCustomers,
|
||||
returning,
|
||||
total,
|
||||
newPercentage: total > 0 ? Math.round((newCustomers / total) * 100) : 0,
|
||||
returningPercentage: total > 0 ? Math.round((returning / total) * 100) : 0,
|
||||
chartData: [
|
||||
{ name: 'New', value: newCustomers, color: '#8b5cf6' },
|
||||
{ name: 'Returning', value: returning, color: '#10b981' },
|
||||
],
|
||||
};
|
||||
}, [customers]);
|
||||
|
||||
return (
|
||||
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className={`text-base font-semibold text-gray-900 dark:text-white mb-2 ${isEditing ? 'pl-5' : ''}`}>
|
||||
Customers This Month
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 flex items-center gap-3 min-h-0">
|
||||
{/* Pie Chart */}
|
||||
<div className="w-20 h-20 flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={breakdownData.chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={20}
|
||||
outerRadius={35}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{breakdownData.chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<UserPlus size={12} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">New</p>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{breakdownData.new}{' '}
|
||||
<span className="text-xs font-normal text-gray-400">
|
||||
({breakdownData.newPercentage}%)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<UserCheck size={12} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Returning</p>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{breakdownData.returning}{' '}
|
||||
<span className="text-xs font-normal text-gray-400">
|
||||
({breakdownData.returningPercentage}%)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<Users size={12} />
|
||||
<span>{t('dashboard.totalCustomers')}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{breakdownData.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerBreakdownWidget;
|
||||
92
frontend/src/components/dashboard/MetricWidget.tsx
Normal file
92
frontend/src/components/dashboard/MetricWidget.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, GripVertical, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GrowthData {
|
||||
weekly: { value: number; change: number };
|
||||
monthly: { value: number; change: number };
|
||||
}
|
||||
|
||||
interface MetricWidgetProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
growth: GrowthData;
|
||||
icon?: React.ReactNode;
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const MetricWidget: React.FC<MetricWidgetProps> = ({
|
||||
title,
|
||||
value,
|
||||
growth,
|
||||
icon,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const formatChange = (change: number) => {
|
||||
if (change === 0) return '0%';
|
||||
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const getTrendIcon = (change: number) => {
|
||||
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
|
||||
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
|
||||
return <Minus size={12} className="mr-1" />;
|
||||
};
|
||||
|
||||
const getTrendClass = (change: number) => {
|
||||
if (change > 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
|
||||
if (change < 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
|
||||
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={isEditing ? 'pl-5' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{icon && <span className="text-brand-500">{icon}</span>}
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.weekLabel')}</span>
|
||||
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.weekly.change)}`}>
|
||||
{getTrendIcon(growth.weekly.change)}
|
||||
{formatChange(growth.weekly.change)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.monthLabel')}</span>
|
||||
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.monthly.change)}`}>
|
||||
{getTrendIcon(growth.monthly.change)}
|
||||
{formatChange(growth.monthly.change)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricWidget;
|
||||
147
frontend/src/components/dashboard/NoShowRateWidget.tsx
Normal file
147
frontend/src/components/dashboard/NoShowRateWidget.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, X, UserX, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { Appointment } from '../../types';
|
||||
import { subDays, subMonths, isAfter } from 'date-fns';
|
||||
|
||||
interface NoShowRateWidgetProps {
|
||||
appointments: Appointment[];
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
|
||||
appointments,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const noShowData = useMemo(() => {
|
||||
const now = new Date();
|
||||
const oneWeekAgo = subDays(now, 7);
|
||||
const twoWeeksAgo = subDays(now, 14);
|
||||
const oneMonthAgo = subMonths(now, 1);
|
||||
const twoMonthsAgo = subMonths(now, 2);
|
||||
|
||||
// Calculate rates for different periods
|
||||
const calculateRate = (appts: Appointment[]) => {
|
||||
const completed = appts.filter(
|
||||
(a) => a.status === 'COMPLETED' || a.status === 'NO_SHOW' || a.status === 'CANCELLED'
|
||||
);
|
||||
const noShows = completed.filter((a) => a.status === 'NO_SHOW');
|
||||
return completed.length > 0 ? (noShows.length / completed.length) * 100 : 0;
|
||||
};
|
||||
|
||||
// Current week
|
||||
const thisWeekAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneWeekAgo));
|
||||
const currentWeekRate = calculateRate(thisWeekAppts);
|
||||
|
||||
// Last week
|
||||
const lastWeekAppts = appointments.filter(
|
||||
(a) => isAfter(new Date(a.startTime), twoWeeksAgo) && !isAfter(new Date(a.startTime), oneWeekAgo)
|
||||
);
|
||||
const lastWeekRate = calculateRate(lastWeekAppts);
|
||||
|
||||
// Current month
|
||||
const thisMonthAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneMonthAgo));
|
||||
const currentMonthRate = calculateRate(thisMonthAppts);
|
||||
|
||||
// Last month
|
||||
const lastMonthAppts = appointments.filter(
|
||||
(a) => isAfter(new Date(a.startTime), twoMonthsAgo) && !isAfter(new Date(a.startTime), oneMonthAgo)
|
||||
);
|
||||
const lastMonthRate = calculateRate(lastMonthAppts);
|
||||
|
||||
// Calculate changes (negative is good for no-show rate)
|
||||
const weeklyChange = lastWeekRate !== 0 ? ((currentWeekRate - lastWeekRate) / lastWeekRate) * 100 : 0;
|
||||
const monthlyChange = lastMonthRate !== 0 ? ((currentMonthRate - lastMonthRate) / lastMonthRate) * 100 : 0;
|
||||
|
||||
// Count total no-shows this month
|
||||
const noShowsThisMonth = thisMonthAppts.filter((a) => a.status === 'NO_SHOW').length;
|
||||
|
||||
return {
|
||||
currentRate: currentMonthRate,
|
||||
noShowCount: noShowsThisMonth,
|
||||
weeklyChange,
|
||||
monthlyChange,
|
||||
};
|
||||
}, [appointments]);
|
||||
|
||||
const formatChange = (change: number) => {
|
||||
if (change === 0) return '0%';
|
||||
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// For no-show rate, down is good (green), up is bad (red)
|
||||
const getTrendIcon = (change: number) => {
|
||||
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
|
||||
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
|
||||
return <Minus size={12} className="mr-1" />;
|
||||
};
|
||||
|
||||
const getTrendClass = (change: number) => {
|
||||
if (change < 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
|
||||
if (change > 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
|
||||
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const getRateColor = (rate: number) => {
|
||||
if (rate <= 5) return 'text-green-600 dark:text-green-400';
|
||||
if (rate <= 10) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={isEditing ? 'pl-5' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserX size={18} className="text-gray-400" />
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{t('dashboard.noShowRate')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className={`text-2xl font-bold ${getRateColor(noShowData.currentRate)}`}>
|
||||
{noShowData.currentRate.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({noShowData.noShowCount} {t('dashboard.thisMonth')})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs mt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.week')}:</span>
|
||||
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.weeklyChange)}`}>
|
||||
{getTrendIcon(noShowData.weeklyChange)}
|
||||
{formatChange(noShowData.weeklyChange)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">{t('dashboard.month')}:</span>
|
||||
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.monthlyChange)}`}>
|
||||
{getTrendIcon(noShowData.monthlyChange)}
|
||||
{formatChange(noShowData.monthlyChange)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoShowRateWidget;
|
||||
123
frontend/src/components/dashboard/OpenTicketsWidget.tsx
Normal file
123
frontend/src/components/dashboard/OpenTicketsWidget.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react';
|
||||
import { Ticket } from '../../types';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface OpenTicketsWidgetProps {
|
||||
tickets: Ticket[];
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
|
||||
tickets,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const openTickets = tickets.filter(ticket => ticket.status === 'open' || ticket.status === 'in_progress');
|
||||
const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length;
|
||||
|
||||
const getPriorityColor = (priority: string, isOverdue?: boolean) => {
|
||||
if (isOverdue) return 'text-red-600 dark:text-red-400';
|
||||
switch (priority) {
|
||||
case 'urgent': return 'text-red-600 dark:text-red-400';
|
||||
case 'high': return 'text-orange-600 dark:text-orange-400';
|
||||
case 'medium': return 'text-yellow-600 dark:text-yellow-400';
|
||||
default: return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBg = (priority: string, isOverdue?: boolean) => {
|
||||
if (isOverdue) return 'bg-red-50 dark:bg-red-900/20';
|
||||
switch (priority) {
|
||||
case 'urgent': return 'bg-red-50 dark:bg-red-900/20';
|
||||
case 'high': return 'bg-orange-50 dark:bg-orange-900/20';
|
||||
case 'medium': return 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||
default: return 'bg-gray-50 dark:bg-gray-700/50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center justify-between mb-4 ${isEditing ? 'pl-5' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Open Tickets
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{urgentCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
{urgentCount} urgent
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{openTickets.length} open
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{openTickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
|
||||
<AlertCircle size={32} className="mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('dashboard.noOpenTickets')}</p>
|
||||
</div>
|
||||
) : (
|
||||
openTickets.slice(0, 5).map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to="/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">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{ticket.subject}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={getPriorityColor(ticket.priority, ticket.isOverdue)}>
|
||||
{ticket.isOverdue ? 'Overdue' : ticket.priority}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openTickets.length > 5 && (
|
||||
<Link
|
||||
to="/tickets"
|
||||
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
|
||||
>
|
||||
View all {openTickets.length} tickets
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenTicketsWidget;
|
||||
146
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
146
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GripVertical, X, Calendar, UserPlus, XCircle, CheckCircle, DollarSign } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Appointment, Customer } from '../../types';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: 'booking' | 'cancellation' | 'completion' | 'new_customer' | 'payment';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
interface RecentActivityWidgetProps {
|
||||
appointments: Appointment[];
|
||||
customers: Customer[];
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const RecentActivityWidget: React.FC<RecentActivityWidgetProps> = ({
|
||||
appointments,
|
||||
customers,
|
||||
isEditing,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const activities = useMemo(() => {
|
||||
const items: ActivityItem[] = [];
|
||||
|
||||
// Add appointments as activity
|
||||
appointments.forEach((appt) => {
|
||||
const timestamp = new Date(appt.startTime);
|
||||
|
||||
if (appt.status === 'CONFIRMED' || appt.status === 'PENDING') {
|
||||
items.push({
|
||||
id: `booking-${appt.id}`,
|
||||
type: 'booking',
|
||||
title: 'New Booking',
|
||||
description: `${appt.customerName} booked an appointment`,
|
||||
timestamp,
|
||||
icon: <Calendar size={14} />,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
});
|
||||
} else if (appt.status === 'CANCELLED') {
|
||||
items.push({
|
||||
id: `cancel-${appt.id}`,
|
||||
type: 'cancellation',
|
||||
title: 'Cancellation',
|
||||
description: `${appt.customerName} cancelled their appointment`,
|
||||
timestamp,
|
||||
icon: <XCircle size={14} />,
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
});
|
||||
} else if (appt.status === 'COMPLETED') {
|
||||
items.push({
|
||||
id: `complete-${appt.id}`,
|
||||
type: 'completion',
|
||||
title: 'Completed',
|
||||
description: `${appt.customerName}'s appointment completed`,
|
||||
timestamp,
|
||||
icon: <CheckCircle size={14} />,
|
||||
iconBg: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add recent customers (those with no lastVisit are new)
|
||||
customers
|
||||
.filter(c => !c.lastVisit)
|
||||
.slice(0, 5)
|
||||
.forEach((customer) => {
|
||||
items.push({
|
||||
id: `customer-${customer.id}`,
|
||||
type: 'new_customer',
|
||||
title: 'New Customer',
|
||||
description: `${customer.name} signed up`,
|
||||
timestamp: new Date(), // Approximate - would need createdAt field
|
||||
icon: <UserPlus size={14} />,
|
||||
iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by timestamp descending
|
||||
items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
return items.slice(0, 10);
|
||||
}, [appointments, customers]);
|
||||
|
||||
return (
|
||||
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
|
||||
{isEditing && (
|
||||
<>
|
||||
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
|
||||
<Calendar size={32} className="mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('dashboard.noRecentActivity')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3">
|
||||
<div className={`p-1.5 rounded-lg ${activity.iconBg} flex-shrink-0`}>
|
||||
{activity.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{activity.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentActivityWidget;
|
||||
131
frontend/src/components/dashboard/WidgetConfigModal.tsx
Normal file
131
frontend/src/components/dashboard/WidgetConfigModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { X, Plus, Check, LayoutDashboard, BarChart2, Ticket, Activity, Users, UserX, PieChart } from 'lucide-react';
|
||||
import { WIDGET_DEFINITIONS, WidgetType } from './types';
|
||||
|
||||
interface WidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
activeWidgets: string[];
|
||||
onToggleWidget: (widgetId: string) => void;
|
||||
onResetLayout: () => void;
|
||||
}
|
||||
|
||||
const WIDGET_ICONS: Record<WidgetType, React.ReactNode> = {
|
||||
'appointments-metric': <LayoutDashboard size={18} />,
|
||||
'customers-metric': <Users size={18} />,
|
||||
'services-metric': <LayoutDashboard size={18} />,
|
||||
'resources-metric': <LayoutDashboard size={18} />,
|
||||
'revenue-chart': <BarChart2 size={18} />,
|
||||
'appointments-chart': <BarChart2 size={18} />,
|
||||
'open-tickets': <Ticket size={18} />,
|
||||
'recent-activity': <Activity size={18} />,
|
||||
'capacity-utilization': <Users size={18} />,
|
||||
'no-show-rate': <UserX size={18} />,
|
||||
'customer-breakdown': <PieChart size={18} />,
|
||||
};
|
||||
|
||||
const WidgetConfigModal: React.FC<WidgetConfigModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
activeWidgets,
|
||||
onToggleWidget,
|
||||
onResetLayout,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const widgets = Object.values(WIDGET_DEFINITIONS);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Configure Dashboard Widgets
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Select which widgets to show on your dashboard. You can drag widgets to reposition them.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{widgets.map((widget) => {
|
||||
const isActive = activeWidgets.includes(widget.id);
|
||||
return (
|
||||
<button
|
||||
key={widget.id}
|
||||
onClick={() => onToggleWidget(widget.id)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
|
||||
isActive
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
isActive
|
||||
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{WIDGET_ICONS[widget.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isActive
|
||||
? 'text-brand-700 dark:text-brand-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{widget.title}
|
||||
</p>
|
||||
{isActive && (
|
||||
<Check size={14} className="text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{widget.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onResetLayout}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetConfigModal;
|
||||
895
frontend/src/components/dashboard/__tests__/ChartWidget.test.tsx
Normal file
895
frontend/src/components/dashboard/__tests__/ChartWidget.test.tsx
Normal file
@@ -0,0 +1,895 @@
|
||||
/**
|
||||
* Unit tests for ChartWidget component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Chart container rendering
|
||||
* - Title display
|
||||
* - Bar chart rendering
|
||||
* - Line chart rendering
|
||||
* - Data visualization
|
||||
* - Custom colors
|
||||
* - Value prefixes
|
||||
* - Edit mode with drag handle and remove button
|
||||
* - Tooltip formatting
|
||||
* - Responsive container
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import ChartWidget from '../ChartWidget';
|
||||
|
||||
// Mock Recharts components to avoid rendering issues in tests
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
BarChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
|
||||
<div data-testid="bar-chart" data-chart-data={JSON.stringify(data)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
LineChart: ({ data, children }: { data: any[]; children: React.ReactNode }) => (
|
||||
<div data-testid="line-chart" data-chart-data={JSON.stringify(data)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Bar: ({ dataKey, fill }: { dataKey: string; fill: string }) => (
|
||||
<div data-testid="bar" data-key={dataKey} data-fill={fill} />
|
||||
),
|
||||
Line: ({ dataKey, stroke }: { dataKey: string; stroke: string }) => (
|
||||
<div data-testid="line" data-key={dataKey} data-stroke={stroke} />
|
||||
),
|
||||
XAxis: ({ dataKey }: { dataKey: string }) => (
|
||||
<div data-testid="x-axis" data-key={dataKey} />
|
||||
),
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid" />,
|
||||
Tooltip: () => <div data-testid="tooltip" />,
|
||||
}));
|
||||
|
||||
describe('ChartWidget', () => {
|
||||
const mockChartData = [
|
||||
{ name: 'Mon', value: 100 },
|
||||
{ name: 'Tue', value: 150 },
|
||||
{ name: 'Wed', value: 120 },
|
||||
{ name: 'Thu', value: 180 },
|
||||
{ name: 'Fri', value: 200 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue Chart"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue Chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render chart container', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue Chart"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('responsive-container');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with different titles', () => {
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Appointments"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Appointments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty data array', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Empty Chart"
|
||||
data={[]}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Empty Chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Title', () => {
|
||||
it('should display title with correct styling', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Weekly Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Weekly Revenue');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-lg', 'font-semibold', 'text-gray-900');
|
||||
});
|
||||
|
||||
it('should apply dark mode styles to title', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should handle long titles', () => {
|
||||
const longTitle = 'Very Long Chart Title That Should Still Display Properly Without Breaking Layout';
|
||||
render(
|
||||
<ChartWidget
|
||||
title={longTitle}
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bar Chart', () => {
|
||||
it('should render bar chart when type is "bar"', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass data to bar chart', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const barChart = screen.getByTestId('bar-chart');
|
||||
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toEqual(mockChartData);
|
||||
});
|
||||
|
||||
it('should render bar with correct dataKey', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('bar');
|
||||
expect(bar).toHaveAttribute('data-key', 'value');
|
||||
});
|
||||
|
||||
it('should render bar with default color', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('bar');
|
||||
expect(bar).toHaveAttribute('data-fill', '#3b82f6');
|
||||
});
|
||||
|
||||
it('should render bar with custom color', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
color="#10b981"
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('bar');
|
||||
expect(bar).toHaveAttribute('data-fill', '#10b981');
|
||||
});
|
||||
|
||||
it('should render CartesianGrid for bar chart', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render XAxis with name dataKey', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const xAxis = screen.getByTestId('x-axis');
|
||||
expect(xAxis).toHaveAttribute('data-key', 'name');
|
||||
});
|
||||
|
||||
it('should render YAxis', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('y-axis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Tooltip', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('tooltip')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Line Chart', () => {
|
||||
it('should render line chart when type is "line"', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass data to line chart', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
const lineChart = screen.getByTestId('line-chart');
|
||||
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toEqual(mockChartData);
|
||||
});
|
||||
|
||||
it('should render line with correct dataKey', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
const line = screen.getByTestId('line');
|
||||
expect(line).toHaveAttribute('data-key', 'value');
|
||||
});
|
||||
|
||||
it('should render line with default color', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
const line = screen.getByTestId('line');
|
||||
expect(line).toHaveAttribute('data-stroke', '#3b82f6');
|
||||
});
|
||||
|
||||
it('should render line with custom color', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
color="#ef4444"
|
||||
/>
|
||||
);
|
||||
|
||||
const line = screen.getByTestId('line');
|
||||
expect(line).toHaveAttribute('data-stroke', '#ef4444');
|
||||
});
|
||||
|
||||
it('should render CartesianGrid for line chart', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('cartesian-grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch between chart types', () => {
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Value Prefix', () => {
|
||||
it('should use empty prefix by default', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Appointments"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
// Component renders successfully without prefix
|
||||
expect(screen.getByText('Appointments')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom value prefix', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
valuePrefix="$"
|
||||
/>
|
||||
);
|
||||
|
||||
// Component renders successfully with prefix
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept different prefixes', () => {
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
valuePrefix="$"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
valuePrefix="€"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should not show edit controls when isEditing is false', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show drag handle when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show remove button when in edit mode', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const removeButton = screen.getByRole('button');
|
||||
expect(removeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onRemove when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
const removeButton = screen.getByRole('button');
|
||||
await user.click(removeButton);
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply padding to title when in edit mode', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).toHaveClass('pl-5');
|
||||
});
|
||||
|
||||
it('should not apply padding to title when not in edit mode', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).not.toHaveClass('pl-5');
|
||||
});
|
||||
|
||||
it('should have grab cursor on drag handle', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).toHaveClass('cursor-grab', 'active:cursor-grabbing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Container', () => {
|
||||
it('should render ResponsiveContainer', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should wrap chart in responsive container', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('responsive-container');
|
||||
const barChart = screen.getByTestId('bar-chart');
|
||||
|
||||
expect(container).toContainElement(barChart);
|
||||
});
|
||||
|
||||
it('should have flex layout for proper sizing', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass('flex', 'flex-col');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply container styles', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass(
|
||||
'h-full',
|
||||
'p-4',
|
||||
'bg-white',
|
||||
'rounded-xl',
|
||||
'border',
|
||||
'border-gray-200',
|
||||
'shadow-sm',
|
||||
'relative',
|
||||
'group'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('should have proper spacing for title', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).toHaveClass('mb-4');
|
||||
});
|
||||
|
||||
it('should use flex-1 for chart container', () => {
|
||||
const { container } = render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const chartContainer = container.querySelector('.flex-1');
|
||||
expect(chartContainer).toBeInTheDocument();
|
||||
expect(chartContainer).toHaveClass('min-h-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Handling', () => {
|
||||
it('should handle single data point', () => {
|
||||
const singlePoint = [{ name: 'Mon', value: 100 }];
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={singlePoint}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const barChart = screen.getByTestId('bar-chart');
|
||||
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toEqual(singlePoint);
|
||||
});
|
||||
|
||||
it('should handle large datasets', () => {
|
||||
const largeData = Array.from({ length: 100 }, (_, i) => ({
|
||||
name: `Day ${i + 1}`,
|
||||
value: Math.random() * 1000,
|
||||
}));
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={largeData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
const lineChart = screen.getByTestId('line-chart');
|
||||
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const zeroData = [
|
||||
{ name: 'Mon', value: 0 },
|
||||
{ name: 'Tue', value: 0 },
|
||||
];
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={zeroData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const barChart = screen.getByTestId('bar-chart');
|
||||
const chartData = JSON.parse(barChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toEqual(zeroData);
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
const negativeData = [
|
||||
{ name: 'Mon', value: -50 },
|
||||
{ name: 'Tue', value: 100 },
|
||||
{ name: 'Wed', value: -30 },
|
||||
];
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Profit/Loss"
|
||||
data={negativeData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
const lineChart = screen.getByTestId('line-chart');
|
||||
const chartData = JSON.parse(lineChart.getAttribute('data-chart-data') || '[]');
|
||||
expect(chartData).toEqual(negativeData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic heading for title', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue Chart"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent('Revenue Chart');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible in edit mode', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
isEditing={true}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper color contrast', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).toHaveClass('text-gray-900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props', () => {
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Weekly Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
color="#10b981"
|
||||
valuePrefix="$"
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Weekly Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar')).toHaveAttribute('data-fill', '#10b981');
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should work with minimal props', () => {
|
||||
render(
|
||||
<ChartWidget
|
||||
title="Simple Chart"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Simple Chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain layout with varying data lengths', () => {
|
||||
const shortData = [{ name: 'A', value: 1 }];
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Data"
|
||||
data={shortData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
|
||||
const longData = Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `Item ${i}`,
|
||||
value: i * 10,
|
||||
}));
|
||||
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Data"
|
||||
data={longData}
|
||||
type="bar"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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) => {
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Revenue"
|
||||
data={mockChartData}
|
||||
type="bar"
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('bar');
|
||||
expect(bar).toHaveAttribute('data-fill', color);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rapid data updates', () => {
|
||||
const { rerender } = render(
|
||||
<ChartWidget
|
||||
title="Live Data"
|
||||
data={mockChartData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const newData = mockChartData.map((item) => ({
|
||||
...item,
|
||||
value: item.value + Math.random() * 50,
|
||||
}));
|
||||
|
||||
rerender(
|
||||
<ChartWidget
|
||||
title="Live Data"
|
||||
data={newData}
|
||||
type="line"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Live Data')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* Unit tests for MetricWidget component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with title and value
|
||||
* - Growth/trend indicators (positive, negative, neutral)
|
||||
* - Change percentage formatting
|
||||
* - Weekly and monthly metrics display
|
||||
* - Icon rendering
|
||||
* - Edit mode with drag handle and remove button
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import MetricWidget from '../MetricWidget';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dashboard.weekLabel': 'Week:',
|
||||
'dashboard.monthLabel': 'Month:',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MetricWidget', () => {
|
||||
const mockGrowthData = {
|
||||
weekly: { value: 100, change: 5.5 },
|
||||
monthly: { value: 400, change: -2.3 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Total Revenue"
|
||||
value="$12,345"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title correctly', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Total Customers"
|
||||
value={150}
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Total Customers');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-sm', 'font-medium', 'text-gray-500');
|
||||
});
|
||||
|
||||
it('should render numeric value', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Total Appointments"
|
||||
value={42}
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const value = screen.getByText('42');
|
||||
expect(value).toBeInTheDocument();
|
||||
expect(value).toHaveClass('text-2xl', 'font-bold', 'text-gray-900');
|
||||
});
|
||||
|
||||
it('should render string value', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$25,000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const value = screen.getByText('$25,000');
|
||||
expect(value).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom icon', () => {
|
||||
const CustomIcon = () => <span data-testid="custom-icon">💰</span>;
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
icon={<CustomIcon />}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without icon', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.text-brand-500');
|
||||
expect(iconContainer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trend Indicators', () => {
|
||||
describe('Positive Change', () => {
|
||||
it('should show positive trend icon for weekly growth', () => {
|
||||
const positiveGrowth = {
|
||||
weekly: { value: 100, change: 10.5 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={positiveGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeText = screen.getByText('+10.5%');
|
||||
expect(changeText).toBeInTheDocument();
|
||||
|
||||
// Check for TrendingUp icon (lucide-react renders as SVG)
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply positive change styling', () => {
|
||||
const positiveGrowth = {
|
||||
weekly: { value: 100, change: 15 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={positiveGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeElement = screen.getByText('+15.0%').closest('span');
|
||||
expect(changeElement).toHaveClass('text-green-700', 'bg-green-50');
|
||||
});
|
||||
|
||||
it('should format positive change with plus sign', () => {
|
||||
const positiveGrowth = {
|
||||
weekly: { value: 100, change: 7.8 },
|
||||
monthly: { value: 400, change: 3.2 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={positiveGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('+7.8%')).toBeInTheDocument();
|
||||
expect(screen.getByText('+3.2%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Negative Change', () => {
|
||||
it('should show negative trend icon for monthly growth', () => {
|
||||
const negativeGrowth = {
|
||||
weekly: { value: 100, change: 0 },
|
||||
monthly: { value: 400, change: -5.5 },
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={negativeGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeText = screen.getByText('-5.5%');
|
||||
expect(changeText).toBeInTheDocument();
|
||||
|
||||
// Check for TrendingDown icon
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply negative change styling', () => {
|
||||
const negativeGrowth = {
|
||||
weekly: { value: 100, change: -12.3 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={negativeGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeElement = screen.getByText('-12.3%').closest('span');
|
||||
expect(changeElement).toHaveClass('text-red-700', 'bg-red-50');
|
||||
});
|
||||
|
||||
it('should format negative change without extra minus sign', () => {
|
||||
const negativeGrowth = {
|
||||
weekly: { value: 100, change: -8.9 },
|
||||
monthly: { value: 400, change: -15.2 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={negativeGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('-8.9%')).toBeInTheDocument();
|
||||
expect(screen.getByText('-15.2%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zero Change', () => {
|
||||
it('should show neutral trend icon for zero change', () => {
|
||||
const zeroGrowth = {
|
||||
weekly: { value: 100, change: 0 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={zeroGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeTexts = screen.getAllByText('0%');
|
||||
expect(changeTexts).toHaveLength(2);
|
||||
|
||||
// Check for Minus icon
|
||||
const svgs = container.querySelectorAll('svg');
|
||||
expect(svgs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply neutral change styling', () => {
|
||||
const zeroGrowth = {
|
||||
weekly: { value: 100, change: 0 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={zeroGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeElements = screen.getAllByText('0%');
|
||||
changeElements.forEach((element) => {
|
||||
const spanElement = element.closest('span');
|
||||
expect(spanElement).toHaveClass('text-gray-700', 'bg-gray-50');
|
||||
});
|
||||
});
|
||||
|
||||
it('should format zero change as 0%', () => {
|
||||
const zeroGrowth = {
|
||||
weekly: { value: 100, change: 0 },
|
||||
monthly: { value: 400, change: 0 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={zeroGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
const changeTexts = screen.getAllByText('0%');
|
||||
expect(changeTexts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weekly and Monthly Metrics', () => {
|
||||
it('should display weekly label', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Week:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display monthly label', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Month:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display weekly change percentage', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('+5.5%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display monthly change percentage', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('-2.3%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different weekly and monthly trends', () => {
|
||||
const mixedGrowth = {
|
||||
weekly: { value: 100, change: 12.5 },
|
||||
monthly: { value: 400, change: -8.2 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mixedGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('+12.5%')).toBeInTheDocument();
|
||||
expect(screen.getByText('-8.2%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format change values to one decimal place', () => {
|
||||
const preciseGrowth = {
|
||||
weekly: { value: 100, change: 5.456 },
|
||||
monthly: { value: 400, change: -3.789 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={preciseGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('+5.5%')).toBeInTheDocument();
|
||||
expect(screen.getByText('-3.8%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should not show edit controls when isEditing is false', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show drag handle when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dragHandle = container.querySelector('.drag-handle');
|
||||
expect(dragHandle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show remove button when in edit mode', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={true}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const removeButton = screen.getByRole('button');
|
||||
expect(removeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onRemove when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleRemove = vi.fn();
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
const removeButton = screen.getByRole('button');
|
||||
await user.click(removeButton);
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply padding when in edit mode', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const contentContainer = container.querySelector('.pl-5');
|
||||
expect(contentContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not apply padding when not in edit mode', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const contentContainer = container.querySelector('.pl-5');
|
||||
expect(contentContainer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply container styles', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass(
|
||||
'h-full',
|
||||
'p-4',
|
||||
'bg-white',
|
||||
'rounded-xl',
|
||||
'border',
|
||||
'border-gray-200',
|
||||
'shadow-sm',
|
||||
'relative',
|
||||
'group'
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply dark mode styles', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const widget = container.firstChild;
|
||||
expect(widget).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('should apply trend badge styles', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const badges = container.querySelectorAll('.rounded-full');
|
||||
expect(badges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic HTML structure', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
const divs = container.querySelectorAll('div');
|
||||
|
||||
expect(paragraphs.length).toBeGreaterThan(0);
|
||||
expect(divs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have readable text contrast', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Revenue');
|
||||
expect(title).toHaveClass('text-gray-500');
|
||||
});
|
||||
|
||||
it('should make remove button accessible when in edit mode', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
isEditing={true}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for week label', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Week:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for month label', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Month:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with all props', () => {
|
||||
const CustomIcon = () => <span data-testid="icon">📊</span>;
|
||||
const handleRemove = vi.fn();
|
||||
const fullGrowth = {
|
||||
weekly: { value: 150, change: 10 },
|
||||
monthly: { value: 600, change: -5 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Total Revenue"
|
||||
value="$15,000"
|
||||
growth={fullGrowth}
|
||||
icon={<CustomIcon />}
|
||||
isEditing={true}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Total Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('$15,000')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('+10.0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('-5.0%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle edge case values', () => {
|
||||
const edgeCaseGrowth = {
|
||||
weekly: { value: 0, change: 0 },
|
||||
monthly: { value: 1000000, change: 99.9 },
|
||||
};
|
||||
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Edge Case"
|
||||
value={0}
|
||||
growth={edgeCaseGrowth}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edge Case')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('+99.9%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain layout with long titles', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Very Long Metric Title That Should Still Display Properly"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText('Very Long Metric Title That Should Still Display Properly');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveClass('text-sm');
|
||||
});
|
||||
|
||||
it('should handle large numeric values', () => {
|
||||
render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1,234,567,890"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('$1,234,567,890')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display multiple trend indicators simultaneously', () => {
|
||||
const { container } = render(
|
||||
<MetricWidget
|
||||
title="Revenue"
|
||||
value="$1000"
|
||||
growth={mockGrowthData}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should have trend indicators for both weekly and monthly
|
||||
const trendBadges = container.querySelectorAll('.rounded-full');
|
||||
expect(trendBadges.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
frontend/src/components/dashboard/index.ts
Normal file
9
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './types';
|
||||
export { default as MetricWidget } from './MetricWidget';
|
||||
export { default as ChartWidget } from './ChartWidget';
|
||||
export { default as OpenTicketsWidget } from './OpenTicketsWidget';
|
||||
export { default as RecentActivityWidget } from './RecentActivityWidget';
|
||||
export { default as CapacityWidget } from './CapacityWidget';
|
||||
export { default as NoShowRateWidget } from './NoShowRateWidget';
|
||||
export { default as CustomerBreakdownWidget } from './CustomerBreakdownWidget';
|
||||
export { default as WidgetConfigModal } from './WidgetConfigModal';
|
||||
146
frontend/src/components/dashboard/types.ts
Normal file
146
frontend/src/components/dashboard/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Layout } from 'react-grid-layout';
|
||||
|
||||
export type WidgetType =
|
||||
| 'appointments-metric'
|
||||
| 'customers-metric'
|
||||
| 'services-metric'
|
||||
| 'resources-metric'
|
||||
| 'revenue-chart'
|
||||
| 'appointments-chart'
|
||||
| 'open-tickets'
|
||||
| 'recent-activity'
|
||||
| 'capacity-utilization'
|
||||
| 'no-show-rate'
|
||||
| 'customer-breakdown';
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string;
|
||||
type: WidgetType;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize?: { w: number; h: number };
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
widgets: string[]; // Widget IDs that are visible
|
||||
layout: Layout[];
|
||||
}
|
||||
|
||||
// Widget definitions with metadata
|
||||
export const WIDGET_DEFINITIONS: Record<WidgetType, WidgetConfig> = {
|
||||
'appointments-metric': {
|
||||
id: 'appointments-metric',
|
||||
type: 'appointments-metric',
|
||||
title: 'Total Appointments',
|
||||
description: 'Shows appointment count with weekly and monthly growth',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
},
|
||||
'customers-metric': {
|
||||
id: 'customers-metric',
|
||||
type: 'customers-metric',
|
||||
title: 'Active Customers',
|
||||
description: 'Shows customer count with weekly and monthly growth',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
},
|
||||
'services-metric': {
|
||||
id: 'services-metric',
|
||||
type: 'services-metric',
|
||||
title: 'Services',
|
||||
description: 'Shows number of services offered',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
},
|
||||
'resources-metric': {
|
||||
id: 'resources-metric',
|
||||
type: 'resources-metric',
|
||||
title: 'Resources',
|
||||
description: 'Shows number of resources available',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
},
|
||||
'revenue-chart': {
|
||||
id: 'revenue-chart',
|
||||
type: 'revenue-chart',
|
||||
title: 'Revenue',
|
||||
description: 'Weekly revenue bar chart',
|
||||
defaultSize: { w: 6, h: 4 },
|
||||
minSize: { w: 4, h: 3 },
|
||||
},
|
||||
'appointments-chart': {
|
||||
id: 'appointments-chart',
|
||||
type: 'appointments-chart',
|
||||
title: 'Appointments Trend',
|
||||
description: 'Weekly appointments line chart',
|
||||
defaultSize: { w: 6, h: 4 },
|
||||
minSize: { w: 4, h: 3 },
|
||||
},
|
||||
'open-tickets': {
|
||||
id: 'open-tickets',
|
||||
type: 'open-tickets',
|
||||
title: 'Open Tickets',
|
||||
description: 'Shows open support tickets requiring attention',
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
},
|
||||
'recent-activity': {
|
||||
id: 'recent-activity',
|
||||
type: 'recent-activity',
|
||||
title: 'Recent Activity',
|
||||
description: 'Timeline of recent business events',
|
||||
defaultSize: { w: 4, h: 5 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
},
|
||||
'capacity-utilization': {
|
||||
id: 'capacity-utilization',
|
||||
type: 'capacity-utilization',
|
||||
title: 'Capacity Utilization',
|
||||
description: 'Shows how booked your resources are this week',
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
},
|
||||
'no-show-rate': {
|
||||
id: 'no-show-rate',
|
||||
type: 'no-show-rate',
|
||||
title: 'No-Show Rate',
|
||||
description: 'Percentage of appointments marked as no-show',
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
},
|
||||
'customer-breakdown': {
|
||||
id: 'customer-breakdown',
|
||||
type: 'customer-breakdown',
|
||||
title: 'New vs Returning',
|
||||
description: 'Customer breakdown this month',
|
||||
defaultSize: { w: 4, h: 4 },
|
||||
minSize: { w: 3, h: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
// Default layout for new users
|
||||
export const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
widgets: [
|
||||
'appointments-metric',
|
||||
'customers-metric',
|
||||
'no-show-rate',
|
||||
'revenue-chart',
|
||||
'appointments-chart',
|
||||
'open-tickets',
|
||||
'recent-activity',
|
||||
'capacity-utilization',
|
||||
'customer-breakdown',
|
||||
],
|
||||
layout: [
|
||||
{ i: 'appointments-metric', x: 0, y: 0, w: 4, h: 2 },
|
||||
{ i: 'customers-metric', x: 4, y: 0, w: 4, h: 2 },
|
||||
{ i: 'no-show-rate', x: 8, y: 0, w: 4, h: 2 },
|
||||
{ i: 'revenue-chart', x: 0, y: 2, w: 6, h: 4 },
|
||||
{ i: 'appointments-chart', x: 6, y: 2, w: 6, h: 4 },
|
||||
{ i: 'open-tickets', x: 0, y: 6, w: 4, h: 4 },
|
||||
{ i: 'recent-activity', x: 4, y: 6, w: 4, h: 4 },
|
||||
{ i: 'capacity-utilization', x: 8, y: 6, w: 4, h: 4 },
|
||||
{ i: 'customer-breakdown', x: 0, y: 10, w: 4, h: 4 },
|
||||
],
|
||||
};
|
||||
@@ -1,33 +1,36 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Rocket, Shield, Zap, Headphones } from 'lucide-react';
|
||||
|
||||
const BenefitsSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'Rapid Deployment',
|
||||
description: 'Launch your branded booking portal in minutes with our pre-configured industry templates.',
|
||||
title: t('marketing.benefits.rapidDeployment.title'),
|
||||
description: t('marketing.benefits.rapidDeployment.description'),
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Enterprise Security',
|
||||
description: 'Sleep soundly knowing your data is physically isolated in its own dedicated secure vault.',
|
||||
title: t('marketing.benefits.enterpriseSecurity.title'),
|
||||
description: t('marketing.benefits.enterpriseSecurity.description'),
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'High Performance',
|
||||
description: 'Built on a modern, edge-cached architecture to ensure instant loading times globally.',
|
||||
title: t('marketing.benefits.highPerformance.title'),
|
||||
description: t('marketing.benefits.highPerformance.description'),
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
},
|
||||
{
|
||||
icon: Headphones,
|
||||
title: 'Expert Support',
|
||||
description: 'Our team of scheduling experts is available to help you optimize your automation workflows.',
|
||||
title: t('marketing.benefits.expertSupport.title'),
|
||||
description: t('marketing.benefits.expertSupport.description'),
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ const Footer: React.FC = () => {
|
||||
<Link to="/" className="flex items-center gap-2 mb-4 group">
|
||||
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Smooth Schedule
|
||||
{t('marketing.footer.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
|
||||
@@ -21,16 +21,16 @@ const Hero: React.FC = () => {
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-50 dark:bg-brand-900/30 border border-brand-100 dark:border-brand-800 mb-6">
|
||||
<span className="flex h-2 w-2 rounded-full bg-brand-600 dark:bg-brand-400 animate-pulse" />
|
||||
<span className="text-sm font-medium text-brand-700 dark:text-brand-300">
|
||||
New: Automation Marketplace
|
||||
{t('marketing.hero.badge')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-gray-900 dark:text-white mb-6">
|
||||
The Operating System for <span className="text-brand-600 dark:text-brand-400">Service Businesses</span>
|
||||
{t('marketing.hero.title')} <span className="text-brand-600 dark:text-brand-400">{t('marketing.hero.titleHighlight')}</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-8 max-w-2xl mx-auto lg:mx-0">
|
||||
Orchestrate your entire operation with intelligent scheduling and powerful automation. No coding required.
|
||||
{t('marketing.hero.description')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-10">
|
||||
@@ -38,7 +38,7 @@ const Hero: React.FC = () => {
|
||||
to="/signup"
|
||||
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-brand-600 hover:bg-brand-700 rounded-lg transition-colors shadow-lg shadow-brand-600/20"
|
||||
>
|
||||
Start Free Trial
|
||||
{t('marketing.hero.startFreeTrial')}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
@@ -46,22 +46,22 @@ const Hero: React.FC = () => {
|
||||
className="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Play className="mr-2 h-5 w-5 fill-current" />
|
||||
Watch Demo
|
||||
{t('marketing.hero.watchDemo')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4 justify-center lg:justify-start text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>No credit card required</span>
|
||||
<span>{t('marketing.hero.noCreditCard')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>14-day free trial</span>
|
||||
<span>{t('marketing.hero.freeTrial')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Cancel anytime</span>
|
||||
<span>{t('marketing.hero.cancelAnytime')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,17 +74,17 @@ const Hero: React.FC = () => {
|
||||
<div className="inline-flex p-4 bg-brand-500/20 rounded-2xl mb-6">
|
||||
<CheckCircle2 className="w-16 h-16 text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">Automated Success</h3>
|
||||
<p className="text-gray-400">Your business, running on autopilot.</p>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">{t('marketing.hero.visualContent.automatedSuccess')}</h3>
|
||||
<p className="text-gray-400">{t('marketing.hero.visualContent.autopilot')}</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-800/50 p-3 rounded-lg border border-gray-700">
|
||||
<div className="text-green-400 font-bold">+24%</div>
|
||||
<div className="text-xs text-gray-500">Revenue</div>
|
||||
<div className="text-xs text-gray-500">{t('marketing.hero.visualContent.revenue')}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800/50 p-3 rounded-lg border border-gray-700">
|
||||
<div className="text-blue-400 font-bold">-40%</div>
|
||||
<div className="text-xs text-gray-500">No-Shows</div>
|
||||
<div className="text-xs text-gray-500">{t('marketing.hero.visualContent.noShows')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +96,8 @@ const Hero: React.FC = () => {
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">Revenue Optimized</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">+$2,400 this week</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{t('marketing.hero.visualContent.revenueOptimized')}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{t('marketing.hero.visualContent.thisWeek')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<SmoothScheduleLogo className="h-12 w-12 text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors" />
|
||||
<span className="text-xl font-bold text-gray-900 dark:text-white hidden sm:block">
|
||||
Smooth Schedule
|
||||
{t('marketing.nav.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -102,7 +102,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={darkMode ? t('marketing.nav.switchToLightMode') : t('marketing.nav.switchToDarkMode')}
|
||||
>
|
||||
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
@@ -136,7 +136,7 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="lg:hidden p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
aria-label={t('marketing.nav.toggleMenu')}
|
||||
>
|
||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeBlock from './CodeBlock';
|
||||
|
||||
const PluginShowcase: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
|
||||
|
||||
@@ -11,69 +13,29 @@ const PluginShowcase: React.FC = () => {
|
||||
{
|
||||
id: 'winback',
|
||||
icon: Mail,
|
||||
title: 'Client Win-Back',
|
||||
description: 'Automatically re-engage customers who haven\'t visited in 60 days.',
|
||||
stats: ['+15% Retention', '$4k/mo Revenue'],
|
||||
title: t('marketing.plugins.examples.winback.title'),
|
||||
description: t('marketing.plugins.examples.winback.description'),
|
||||
stats: [t('marketing.plugins.examples.winback.stats.retention'), t('marketing.plugins.examples.winback.stats.revenue')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-pink-500 to-rose-500',
|
||||
code: `# Win back lost customers
|
||||
days_inactive = 60
|
||||
discount = "20%"
|
||||
|
||||
# Find inactive customers
|
||||
inactive = api.get_customers(
|
||||
last_visit_lt=days_ago(days_inactive)
|
||||
)
|
||||
|
||||
# Send personalized offer
|
||||
for customer in inactive:
|
||||
api.send_email(
|
||||
to=customer.email,
|
||||
subject="We miss you!",
|
||||
body=f"Come back for {discount} off!"
|
||||
)`,
|
||||
code: t('marketing.plugins.examples.winback.code'),
|
||||
},
|
||||
{
|
||||
id: 'noshow',
|
||||
icon: Bell,
|
||||
title: 'No-Show Prevention',
|
||||
description: 'Send SMS reminders 2 hours before appointments to reduce no-shows.',
|
||||
stats: ['-40% No-Shows', 'Better Utilization'],
|
||||
title: t('marketing.plugins.examples.noshow.title'),
|
||||
description: t('marketing.plugins.examples.noshow.description'),
|
||||
stats: [t('marketing.plugins.examples.noshow.stats.reduction'), t('marketing.plugins.examples.noshow.stats.utilization')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-blue-500 to-cyan-500',
|
||||
code: `# Prevent no-shows
|
||||
hours_before = 2
|
||||
|
||||
# Find upcoming appointments
|
||||
upcoming = api.get_appointments(
|
||||
start_time__within=hours(hours_before)
|
||||
)
|
||||
|
||||
# Send SMS reminder
|
||||
for appt in upcoming:
|
||||
api.send_sms(
|
||||
to=appt.customer.phone,
|
||||
body=f"Reminder: Appointment in 2h at {appt.time}"
|
||||
)`,
|
||||
code: t('marketing.plugins.examples.noshow.code'),
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
icon: Calendar,
|
||||
title: 'Daily Reports',
|
||||
description: 'Get a summary of tomorrow\'s schedule sent to your inbox every evening.',
|
||||
stats: ['Save 30min/day', 'Full Visibility'],
|
||||
title: t('marketing.plugins.examples.report.title'),
|
||||
description: t('marketing.plugins.examples.report.description'),
|
||||
stats: [t('marketing.plugins.examples.report.stats.timeSaved'), t('marketing.plugins.examples.report.stats.visibility')],
|
||||
marketplaceImage: 'bg-gradient-to-br from-purple-500 to-indigo-500',
|
||||
code: `# Daily Manager Report
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
|
||||
# Get schedule stats
|
||||
stats = api.get_schedule_stats(date=tomorrow)
|
||||
revenue = api.forecast_revenue(date=tomorrow)
|
||||
|
||||
# Email manager
|
||||
api.send_email(
|
||||
to="manager@business.com",
|
||||
subject=f"Schedule for {tomorrow}",
|
||||
body=f"Bookings: {stats.count}, Est. Rev: \${revenue}"
|
||||
)`,
|
||||
code: t('marketing.plugins.examples.report.code'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,16 +50,15 @@ api.send_email(
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 text-sm font-medium mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>Limitless Automation</span>
|
||||
<span>{t('marketing.plugins.badge')}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Choose from our Marketplace, or build your own.
|
||||
{t('marketing.plugins.headline')}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-10">
|
||||
Browse hundreds of pre-built plugins to automate your workflows instantly.
|
||||
Need something custom? Developers can write Python scripts to extend the platform endlessly.
|
||||
{t('marketing.plugins.subheadline')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -147,7 +108,7 @@ api.send_email(
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
Marketplace
|
||||
{t('marketing.plugins.viewToggle.marketplace')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('code')}
|
||||
@@ -157,7 +118,7 @@ api.send_email(
|
||||
}`}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
Developer
|
||||
{t('marketing.plugins.viewToggle.developer')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -190,10 +151,10 @@ api.send_email(
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{examples[activeTab].title}</h3>
|
||||
<div className="text-sm text-gray-500">by SmoothSchedule Team</div>
|
||||
<div className="text-sm text-gray-500">{t('marketing.plugins.marketplaceCard.author')}</div>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-brand-600 text-white rounded-lg font-medium text-sm hover:bg-brand-700 transition-colors">
|
||||
Install Plugin
|
||||
{t('marketing.plugins.marketplaceCard.installButton')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
@@ -205,7 +166,7 @@ api.send_email(
|
||||
<div key={i} className="w-6 h-6 rounded-full bg-gray-300 border-2 border-white dark:border-gray-800" />
|
||||
))}
|
||||
</div>
|
||||
<span>Used by 1,200+ businesses</span>
|
||||
<span>{t('marketing.plugins.marketplaceCard.usedBy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +181,7 @@ api.send_email(
|
||||
{/* CTA */}
|
||||
<div className="mt-6 text-right">
|
||||
<a href="/features" className="inline-flex items-center gap-2 text-brand-600 dark:text-brand-400 font-medium hover:underline">
|
||||
Explore the Marketplace <ArrowRight className="w-4 h-4" />
|
||||
{t('marketing.plugins.cta')} <ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,69 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PricingTable: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
name: t('marketing.pricing.tiers.starter.name'),
|
||||
price: '$0',
|
||||
period: '/month',
|
||||
description: 'Perfect for solo practitioners and small studios.',
|
||||
period: t('marketing.pricing.perMonth'),
|
||||
description: t('marketing.pricing.tiers.starter.description'),
|
||||
features: [
|
||||
'1 User',
|
||||
'Unlimited Appointments',
|
||||
'1 Active Automation',
|
||||
'Basic Reporting',
|
||||
'Email Support',
|
||||
t('marketing.pricing.tiers.starter.features.0'),
|
||||
t('marketing.pricing.tiers.starter.features.1'),
|
||||
t('marketing.pricing.tiers.starter.features.2'),
|
||||
t('marketing.pricing.tiers.starter.features.3'),
|
||||
t('marketing.pricing.tiers.starter.features.4'),
|
||||
],
|
||||
notIncluded: [
|
||||
'Custom Domain',
|
||||
'Python Scripting',
|
||||
'White-Labeling',
|
||||
'Priority Support',
|
||||
t('marketing.pricing.tiers.starter.notIncluded.0'),
|
||||
t('marketing.pricing.tiers.starter.notIncluded.1'),
|
||||
t('marketing.pricing.tiers.starter.notIncluded.2'),
|
||||
t('marketing.pricing.tiers.starter.notIncluded.3'),
|
||||
],
|
||||
cta: 'Start Free',
|
||||
cta: t('marketing.pricing.tiers.starter.cta'),
|
||||
ctaLink: '/signup',
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
name: t('marketing.pricing.tiers.pro.name'),
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
description: 'For growing businesses that need automation.',
|
||||
period: t('marketing.pricing.perMonth'),
|
||||
description: t('marketing.pricing.tiers.pro.description'),
|
||||
features: [
|
||||
'5 Users',
|
||||
'Unlimited Appointments',
|
||||
'5 Active Automations',
|
||||
'Advanced Reporting',
|
||||
'Priority Email Support',
|
||||
'SMS Reminders',
|
||||
t('marketing.pricing.tiers.pro.features.0'),
|
||||
t('marketing.pricing.tiers.pro.features.1'),
|
||||
t('marketing.pricing.tiers.pro.features.2'),
|
||||
t('marketing.pricing.tiers.pro.features.3'),
|
||||
t('marketing.pricing.tiers.pro.features.4'),
|
||||
t('marketing.pricing.tiers.pro.features.5'),
|
||||
],
|
||||
notIncluded: [
|
||||
'Custom Domain',
|
||||
'Python Scripting',
|
||||
'White-Labeling',
|
||||
t('marketing.pricing.tiers.pro.notIncluded.0'),
|
||||
t('marketing.pricing.tiers.pro.notIncluded.1'),
|
||||
t('marketing.pricing.tiers.pro.notIncluded.2'),
|
||||
],
|
||||
cta: 'Start Trial',
|
||||
cta: t('marketing.pricing.tiers.pro.cta'),
|
||||
ctaLink: '/signup?plan=pro',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: 'Business',
|
||||
name: t('marketing.pricing.tiers.business.name'),
|
||||
price: '$99',
|
||||
period: '/month',
|
||||
description: 'Full power of the platform for serious operations.',
|
||||
period: t('marketing.pricing.perMonth'),
|
||||
description: t('marketing.pricing.tiers.business.description'),
|
||||
features: [
|
||||
'Unlimited Users',
|
||||
'Unlimited Appointments',
|
||||
'Unlimited Automations',
|
||||
'Custom Python Scripts',
|
||||
'Custom Domain (White-Label)',
|
||||
'Dedicated Support',
|
||||
'API Access',
|
||||
t('marketing.pricing.tiers.business.features.0'),
|
||||
t('marketing.pricing.tiers.business.features.1'),
|
||||
t('marketing.pricing.tiers.business.features.2'),
|
||||
t('marketing.pricing.tiers.business.features.3'),
|
||||
t('marketing.pricing.tiers.business.features.4'),
|
||||
t('marketing.pricing.tiers.business.features.5'),
|
||||
t('marketing.pricing.tiers.business.features.6'),
|
||||
],
|
||||
notIncluded: [],
|
||||
cta: 'Contact Sales',
|
||||
cta: t('marketing.pricing.contactSales'),
|
||||
ctaLink: '/contact',
|
||||
popular: false,
|
||||
},
|
||||
@@ -81,7 +84,7 @@ const PricingTable: React.FC = () => {
|
||||
>
|
||||
{tier.popular && (
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1 bg-brand-500 text-white text-sm font-medium rounded-full">
|
||||
Most Popular
|
||||
{t('marketing.pricing.mostPopular')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
533
frontend/src/components/marketing/__tests__/CTASection.test.tsx
Normal file
533
frontend/src/components/marketing/__tests__/CTASection.test.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Unit tests for CTASection component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering in both variants (default and minimal)
|
||||
* - CTA text rendering
|
||||
* - Button/link presence and navigation
|
||||
* - Click navigation behavior
|
||||
* - Icon display
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility
|
||||
* - Styling variations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import CTASection from '../CTASection';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.cta.ready': 'Ready to get started?',
|
||||
'marketing.cta.readySubtitle': 'Join thousands of businesses already using SmoothSchedule.',
|
||||
'marketing.cta.startFree': 'Get Started Free',
|
||||
'marketing.cta.talkToSales': 'Talk to Sales',
|
||||
'marketing.cta.noCredit': 'No credit card required',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CTASection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Default Variant', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the CTA section', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA text elements', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Main heading
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
// Subtitle
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
|
||||
// No credit card required
|
||||
const disclaimer = screen.getByText(/no credit card required/i);
|
||||
expect(disclaimer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with correct text hierarchy', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button/Link Presence', () => {
|
||||
it('should render the signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the talk to sales button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render both CTA buttons', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have correct href for signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should have correct href for sales button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should navigate when signup button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(signupButton)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should navigate when sales button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(salesButton)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should display ArrowRight icon on signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct icon size', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toHaveClass('h-5', 'w-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply gradient background', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('bg-gradient-to-br', 'from-brand-600', 'to-brand-700');
|
||||
});
|
||||
|
||||
it('should apply correct padding', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-20', 'lg:py-28');
|
||||
});
|
||||
|
||||
it('should style signup button as primary CTA', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('bg-white', 'text-brand-600');
|
||||
expect(signupButton).toHaveClass('hover:bg-brand-50');
|
||||
});
|
||||
|
||||
it('should style sales button as secondary CTA', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveClass('bg-white/10', 'text-white');
|
||||
expect(salesButton).toHaveClass('hover:bg-white/20');
|
||||
});
|
||||
|
||||
it('should have responsive button layout', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const buttonContainer = container.querySelector('.flex.flex-col.sm\\:flex-row');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply shadow to signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('shadow-lg', 'shadow-black/10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Pattern', () => {
|
||||
it('should render decorative background elements', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const backgroundPattern = container.querySelector('.absolute.inset-0');
|
||||
expect(backgroundPattern).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Minimal Variant', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the minimal CTA section', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA text in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only render one button in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button/Link Presence', () => {
|
||||
it('should render only the signup button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the sales button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the disclaimer text', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const disclaimer = screen.queryByText(/no credit card required/i);
|
||||
expect(disclaimer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have correct href for signup button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should navigate when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(signupButton)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should display ArrowRight icon', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct icon size', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toHaveClass('h-5', 'w-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply white background', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('bg-white', 'dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('should apply minimal padding', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-16');
|
||||
});
|
||||
|
||||
it('should use brand colors for button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('bg-brand-600', 'text-white');
|
||||
expect(signupButton).toHaveClass('hover:bg-brand-700');
|
||||
});
|
||||
|
||||
it('should have smaller heading size', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||
});
|
||||
|
||||
it('should not have gradient background', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).not.toHaveClass('bg-gradient-to-br');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variant Comparison', () => {
|
||||
it('should render different layouts for different variants', () => {
|
||||
const { container: defaultContainer } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
const { container: minimalContainer } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const defaultSection = defaultContainer.querySelector('section');
|
||||
const minimalSection = minimalContainer.querySelector('section');
|
||||
|
||||
expect(defaultSection?.className).not.toEqual(minimalSection?.className);
|
||||
});
|
||||
|
||||
it('should use default variant when no variant prop provided', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for elements unique to default variant
|
||||
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch variants correctly', () => {
|
||||
const { rerender } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have 2 buttons in default
|
||||
let links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
|
||||
rerender(<CTASection variant="minimal" />);
|
||||
|
||||
// Should have 1 button in minimal
|
||||
links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for heading', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByText('Ready to get started?');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for subtitle', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText('Join thousands of businesses already using SmoothSchedule.');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for button text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveTextContent('Get Started Free');
|
||||
});
|
||||
|
||||
it('should use translation for sales button text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveTextContent('Talk to Sales');
|
||||
});
|
||||
|
||||
it('should use translation for disclaimer', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const disclaimer = screen.getByText('No credit card required');
|
||||
expect(disclaimer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate all text in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Ready to get started?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Join thousands of businesses already using SmoothSchedule.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveTextContent('Get Started Free');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic section element', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have heading hierarchy', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have keyboard accessible links', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
expect(signupButton.tagName).toBe('A');
|
||||
expect(salesButton.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('should have descriptive link text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
expect(signupButton).toHaveAccessibleName();
|
||||
expect(salesButton).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('should maintain accessibility in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(signupButton).toHaveAccessibleName();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should have responsive heading sizes', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-3xl', 'sm:text-4xl', 'lg:text-5xl');
|
||||
});
|
||||
|
||||
it('should have responsive subtitle size', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toHaveClass('text-lg', 'sm:text-xl');
|
||||
});
|
||||
|
||||
it('should have responsive button layout', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('w-full', 'sm:w-auto');
|
||||
});
|
||||
|
||||
it('should have responsive padding in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with default variant', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.getByRole('link', { name: /talk to sales/i })).toHaveAttribute('href', '/contact');
|
||||
expect(screen.getByText(/no credit card required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correctly with minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.queryByRole('link', { name: /talk to sales/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/no credit card required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure with all elements in place', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
const heading = screen.getByRole('heading');
|
||||
const subtitle = screen.getByText(/join thousands/i);
|
||||
const buttons = screen.getAllByRole('link');
|
||||
|
||||
expect(section).toContainElement(heading);
|
||||
expect(section).toContainElement(subtitle);
|
||||
buttons.forEach(button => {
|
||||
expect(section).toContainElement(button);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
366
frontend/src/components/marketing/__tests__/CodeBlock.test.tsx
Normal file
366
frontend/src/components/marketing/__tests__/CodeBlock.test.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import CodeBlock from '../CodeBlock';
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
// Mock clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const mockWriteText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: originalClipboard,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders code content correctly', () => {
|
||||
const code = 'print("Hello, World!")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
// Check that the code content is rendered (text is within code element)
|
||||
const codeElement = container.querySelector('code');
|
||||
expect(codeElement?.textContent).toContain('print(');
|
||||
// Due to string splitting in regex, checking for function call
|
||||
expect(container.querySelector('.text-blue-400')?.textContent).toContain('print(');
|
||||
});
|
||||
|
||||
it('renders multi-line code with line numbers', () => {
|
||||
const code = 'line 1\nline 2\nline 3';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// Check line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
|
||||
// Check content
|
||||
expect(screen.getByText(/line 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders terminal-style dots', () => {
|
||||
render(<CodeBlock code="test code" />);
|
||||
|
||||
const container = screen.getByRole('button', { name: /copy code/i }).closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// Check for the presence of the terminal-style dots container
|
||||
const dotsContainer = container?.querySelector('.flex.gap-1\\.5');
|
||||
expect(dotsContainer).toBeInTheDocument();
|
||||
expect(dotsContainer?.children).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language and Filename', () => {
|
||||
it('applies default language class when no language specified', () => {
|
||||
const code = 'test code';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const codeElement = screen.getByText(/test code/).closest('code');
|
||||
expect(codeElement).toHaveClass('language-python');
|
||||
});
|
||||
|
||||
it('applies custom language class when specified', () => {
|
||||
const code = 'const x = 1;';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
const codeElement = screen.getByText(/const x = 1/).closest('code');
|
||||
expect(codeElement).toHaveClass('language-javascript');
|
||||
});
|
||||
|
||||
it('displays filename when provided', () => {
|
||||
const code = 'test code';
|
||||
const filename = 'example.py';
|
||||
render(<CodeBlock code={code} filename={filename} />);
|
||||
|
||||
expect(screen.getByText(filename)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display filename when not provided', () => {
|
||||
const code = 'test code';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// The filename element should not exist in the DOM
|
||||
const filenameElement = screen.queryByText(/\.py$/);
|
||||
expect(filenameElement).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Functionality', () => {
|
||||
it('renders copy button', () => {
|
||||
render(<CodeBlock code="test code" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies code to clipboard when copy button is clicked', async () => {
|
||||
const code = 'print("Copy me!")';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(code);
|
||||
});
|
||||
|
||||
it('shows check icon after successful copy', async () => {
|
||||
const code = 'test code';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
|
||||
// Initially should show Copy icon
|
||||
let copyIcon = copyButton.querySelector('svg');
|
||||
expect(copyIcon).toBeInTheDocument();
|
||||
|
||||
// Click to copy
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Should immediately show Check icon (synchronous state update)
|
||||
const checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts to copy icon after 2 seconds', async () => {
|
||||
const code = 'test code';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
|
||||
// Click to copy
|
||||
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
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
// Should revert to Copy icon (check icon should be gone)
|
||||
checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Syntax Highlighting', () => {
|
||||
it('highlights Python comments', () => {
|
||||
const code = '# This is a comment';
|
||||
render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
const commentElement = screen.getByText(/This is a comment/);
|
||||
expect(commentElement).toBeInTheDocument();
|
||||
expect(commentElement).toHaveClass('text-gray-500');
|
||||
});
|
||||
|
||||
it('highlights JavaScript comments', () => {
|
||||
const code = '// This is a comment';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
const commentElement = screen.getByText(/This is a comment/);
|
||||
expect(commentElement).toBeInTheDocument();
|
||||
expect(commentElement).toHaveClass('text-gray-500');
|
||||
});
|
||||
|
||||
it('highlights string literals', () => {
|
||||
const code = 'print("Hello World")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const stringElements = container.querySelectorAll('.text-green-400');
|
||||
expect(stringElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights Python keywords', () => {
|
||||
const code = 'def my_function():';
|
||||
const { container } = render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
const keywordElements = container.querySelectorAll('.text-purple-400');
|
||||
expect(keywordElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights function calls', () => {
|
||||
const code = 'print("test")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const functionElements = container.querySelectorAll('.text-blue-400');
|
||||
expect(functionElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights multiple keywords in a line', () => {
|
||||
const code = 'if True return None';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const keywordElements = container.querySelectorAll('.text-purple-400');
|
||||
// Should highlight 'if', 'True', 'return', and 'None'
|
||||
expect(keywordElements.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not highlight non-keyword words', () => {
|
||||
const code = 'my_variable = 42';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const codeText = screen.getByText(/my_variable/);
|
||||
expect(codeText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Code Examples', () => {
|
||||
it('handles Python code with multiple syntax elements', () => {
|
||||
const code = `def greet(name):
|
||||
# Print a greeting
|
||||
return "Hello, " + name`;
|
||||
|
||||
render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
// Check that all lines are rendered
|
||||
expect(screen.getByText(/def/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Print a greeting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/return/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles JavaScript code', () => {
|
||||
const code = `const greeting = "Hello";
|
||||
// Log the greeting
|
||||
console.log(greeting);`;
|
||||
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
expect(screen.getByText(/const greeting =/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Log the greeting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/console.log/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves indentation and whitespace', () => {
|
||||
const code = `def test():
|
||||
if True:
|
||||
return 1`;
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
// Check for whitespace-pre class which preserves whitespace
|
||||
const codeLines = container.querySelectorAll('.whitespace-pre');
|
||||
expect(codeLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty code string', () => {
|
||||
render(<CodeBlock code="" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles code with only whitespace', () => {
|
||||
const code = ' \n \n ';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// Should still render line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles very long single line', () => {
|
||||
const code = 'x = ' + 'a'.repeat(1000);
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles special characters in code', () => {
|
||||
const code = 'const regex = /[a-z]+/g;';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
expect(screen.getByText(/regex/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles quotes within strings', () => {
|
||||
const code = 'const msg = "test message";';
|
||||
const { container } = render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
// Code should be rendered
|
||||
expect(container.querySelector('code')).toBeInTheDocument();
|
||||
// Should have string highlighting
|
||||
expect(container.querySelectorAll('.text-green-400').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible copy button with title', () => {
|
||||
render(<CodeBlock code="test" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toHaveAttribute('title', 'Copy code');
|
||||
});
|
||||
|
||||
it('uses semantic HTML elements', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const preElement = container.querySelector('pre');
|
||||
const codeElement = container.querySelector('code');
|
||||
|
||||
expect(preElement).toBeInTheDocument();
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('line numbers are not selectable', () => {
|
||||
const { container } = render(<CodeBlock code="line 1\nline 2" />);
|
||||
|
||||
const lineNumbers = container.querySelectorAll('.select-none');
|
||||
expect(lineNumbers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('applies dark theme styling', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const mainContainer = container.querySelector('.bg-gray-900');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies proper border and shadow', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const mainContainer = container.querySelector('.border-gray-800.shadow-2xl');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies monospace font to code', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const preElement = container.querySelector('pre.font-mono');
|
||||
expect(preElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct text colors', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const codeText = container.querySelector('.text-gray-300');
|
||||
expect(codeText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Unit tests for FAQAccordion component
|
||||
*
|
||||
* Tests the FAQ accordion functionality including:
|
||||
* - Rendering questions and answers
|
||||
* - Expanding and collapsing items
|
||||
* - Single-item accordion behavior (only one open at a time)
|
||||
* - Accessibility attributes
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import FAQAccordion from '../FAQAccordion';
|
||||
|
||||
// Test data
|
||||
const mockFAQItems = [
|
||||
{
|
||||
question: 'What is SmoothSchedule?',
|
||||
answer: 'SmoothSchedule is a comprehensive scheduling platform for businesses.',
|
||||
},
|
||||
{
|
||||
question: 'How much does it cost?',
|
||||
answer: 'We offer flexible pricing plans starting at $29/month.',
|
||||
},
|
||||
{
|
||||
question: 'Can I try it for free?',
|
||||
answer: 'Yes! We offer a 14-day free trial with no credit card required.',
|
||||
},
|
||||
];
|
||||
|
||||
describe('FAQAccordion', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render all questions', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
|
||||
expect(screen.getByText('How much does it cost?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Can I try it for free?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render first item as expanded by default', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
// First answer should be visible
|
||||
expect(
|
||||
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Other answers should not be visible
|
||||
expect(
|
||||
screen.queryByText('We offer flexible pricing plans starting at $29/month.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Yes! We offer a 14-day free trial with no credit card required.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty items array', () => {
|
||||
const { container } = render(<FAQAccordion items={[]} />);
|
||||
|
||||
// Should render the container but no items
|
||||
expect(container.querySelector('.space-y-4')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render with single item', () => {
|
||||
const singleItem = [mockFAQItems[0]];
|
||||
render(<FAQAccordion items={singleItem} />);
|
||||
|
||||
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have aria-expanded attribute on buttons', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// First button should be expanded (default)
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Other buttons should be collapsed
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('should update aria-expanded when item is toggled', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const secondButton = buttons[1];
|
||||
|
||||
// Initially collapsed
|
||||
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondButton);
|
||||
|
||||
// Now expanded
|
||||
expect(secondButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should have proper button semantics', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
buttons.forEach((button) => {
|
||||
// Each button should have text content
|
||||
expect(button.textContent).toBeTruthy();
|
||||
|
||||
// Each button should be clickable
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expand/Collapse Behavior', () => {
|
||||
it('should expand answer when question is clicked', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
|
||||
// Answer should be in the document but potentially hidden
|
||||
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially collapsed (max-h-0)
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondQuestion);
|
||||
|
||||
// Now expanded (max-h-96)
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
});
|
||||
|
||||
it('should collapse answer when clicking expanded question', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const answer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially expanded (first item is open by default)
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// Now collapsed
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
it('should collapse answer when clicking it again (toggle)', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially collapsed
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondQuestion);
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click again to collapse
|
||||
fireEvent.click(secondQuestion);
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single Item Accordion Behavior', () => {
|
||||
it('should only allow one item to be expanded at a time', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
const thirdQuestion = screen.getByText('Can I try it for free?');
|
||||
|
||||
const firstAnswer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const secondAnswer = screen.getByText(
|
||||
'We offer flexible pricing plans starting at $29/month.'
|
||||
);
|
||||
const thirdAnswer = screen.getByText(
|
||||
'Yes! We offer a 14-day free trial with no credit card required.'
|
||||
);
|
||||
|
||||
// Initially, first item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
|
||||
// Click second question
|
||||
fireEvent.click(secondQuestion);
|
||||
|
||||
// Now only second item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
|
||||
// Click third question
|
||||
fireEvent.click(thirdQuestion);
|
||||
|
||||
// Now only third item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
|
||||
// Click first question
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// Back to first item expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
it('should close the currently open item when opening another', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// First button is expanded by default
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click second button
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
// First button should now be collapsed, second expanded
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should allow collapsing all items by clicking the open one', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Initially first item is expanded
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// All items should be collapsed
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chevron Icon Rotation', () => {
|
||||
it('should rotate chevron icon when item is expanded', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const firstButton = buttons[0];
|
||||
const secondButton = buttons[1];
|
||||
|
||||
// First item is expanded, so chevron should be rotated
|
||||
const firstChevron = firstButton.querySelector('svg');
|
||||
expect(firstChevron).toHaveClass('rotate-180');
|
||||
|
||||
// Second item is collapsed, so chevron should not be rotated
|
||||
const secondChevron = secondButton.querySelector('svg');
|
||||
expect(secondChevron).not.toHaveClass('rotate-180');
|
||||
|
||||
// Click second button
|
||||
fireEvent.click(secondButton);
|
||||
|
||||
// Now second chevron should be rotated, first should not
|
||||
expect(firstChevron).not.toHaveClass('rotate-180');
|
||||
expect(secondChevron).toHaveClass('rotate-180');
|
||||
});
|
||||
|
||||
it('should toggle chevron rotation when item is clicked multiple times', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstButton = screen.getAllByRole('button')[0];
|
||||
const chevron = firstButton.querySelector('svg');
|
||||
|
||||
// Initially rotated (first item is expanded)
|
||||
expect(chevron).toHaveClass('rotate-180');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).not.toHaveClass('rotate-180');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).toHaveClass('rotate-180');
|
||||
|
||||
// Click to collapse again
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).not.toHaveClass('rotate-180');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle items with long text content', () => {
|
||||
const longTextItems = [
|
||||
{
|
||||
question: 'This is a very long question that might wrap to multiple lines in the UI?',
|
||||
answer:
|
||||
'This is a very long answer with lots of text. ' +
|
||||
'It contains multiple sentences and provides detailed information. ' +
|
||||
'The accordion should handle this gracefully without breaking the layout. ' +
|
||||
'Users should be able to read all of this content when the item is expanded.',
|
||||
},
|
||||
];
|
||||
|
||||
render(<FAQAccordion items={longTextItems} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('This is a very long question that might wrap to multiple lines in the UI?')
|
||||
).toBeInTheDocument();
|
||||
|
||||
const answer = screen.getByText(/This is a very long answer with lots of text/);
|
||||
expect(answer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle items with special characters', () => {
|
||||
const specialCharItems = [
|
||||
{
|
||||
question: 'What about <special> & "characters"?',
|
||||
answer: 'We support all UTF-8 characters: é, ñ, 中文, 日本語!',
|
||||
},
|
||||
];
|
||||
|
||||
render(<FAQAccordion items={specialCharItems} />);
|
||||
|
||||
expect(screen.getByText('What about <special> & "characters"?')).toBeInTheDocument();
|
||||
expect(screen.getByText('We support all UTF-8 characters: é, ñ, 中文, 日本語!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid clicking without breaking', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Rapidly click different buttons
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
fireEvent.click(buttons[2]);
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
// Should still be functional - second button should be expanded
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('should handle clicking on the same item multiple times', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstButton = screen.getAllByRole('button')[0];
|
||||
|
||||
// Initially expanded
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click multiple times
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should apply correct CSS classes for expanded state', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstAnswer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const answerContainer = firstAnswer.closest('.overflow-hidden');
|
||||
|
||||
// Expanded state should have max-h-96
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
expect(answerContainer).toHaveClass('transition-all');
|
||||
expect(answerContainer).toHaveClass('duration-200');
|
||||
});
|
||||
|
||||
it('should apply correct CSS classes for collapsed state', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondAnswer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = secondAnswer.closest('.overflow-hidden');
|
||||
|
||||
// Collapsed state should have max-h-0
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
expect(answerContainer).toHaveClass('overflow-hidden');
|
||||
});
|
||||
|
||||
it('should have proper container structure', () => {
|
||||
const { container } = render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
// Root container should have space-y-4
|
||||
const rootDiv = container.querySelector('.space-y-4');
|
||||
expect(rootDiv).toBeInTheDocument();
|
||||
|
||||
// Each item should have proper styling
|
||||
const itemContainers = container.querySelectorAll('.bg-white');
|
||||
expect(itemContainers).toHaveLength(mockFAQItems.length);
|
||||
|
||||
itemContainers.forEach((item) => {
|
||||
expect(item).toHaveClass('rounded-xl');
|
||||
expect(item).toHaveClass('border');
|
||||
expect(item).toHaveClass('overflow-hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
688
frontend/src/components/marketing/__tests__/FeatureCard.test.tsx
Normal file
688
frontend/src/components/marketing/__tests__/FeatureCard.test.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* Unit tests for FeatureCard component
|
||||
*
|
||||
* Tests the FeatureCard marketing component including:
|
||||
* - Basic rendering with title and description
|
||||
* - Icon rendering with different colors
|
||||
* - CSS classes and styling
|
||||
* - Hover states and animations
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Calendar, Clock, Users, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import FeatureCard from '../FeatureCard';
|
||||
|
||||
describe('FeatureCard', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render with title and description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Easy Scheduling"
|
||||
description="Schedule appointments with ease using our intuitive calendar interface."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Easy Scheduling')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Schedule appointments with ease using our intuitive calendar interface.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with different content', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Team Management"
|
||||
description="Manage your team members and their availability efficiently."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Team Management')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Manage your team members and their availability efficiently.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with long description text', () => {
|
||||
const longDescription =
|
||||
'This is a very long description that contains multiple sentences. It should wrap properly and display all the content. Our feature card component is designed to handle various lengths of text gracefully.';
|
||||
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Time Tracking"
|
||||
description={longDescription}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={CheckCircle}
|
||||
title="Success Tracking"
|
||||
description=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Success Tracking')).toBeInTheDocument();
|
||||
// Empty description should still render the paragraph element
|
||||
const descriptionElement = screen.getByText('Success Tracking').parentElement?.querySelector('p');
|
||||
expect(descriptionElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render the provided icon', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Calendar Feature"
|
||||
description="Calendar description"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for SVG element (icons are rendered as SVG)
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
expect(svgElement).toHaveClass('h-6', 'w-6');
|
||||
});
|
||||
|
||||
it('should render different icons correctly', () => {
|
||||
const { container: container1 } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Feature 1"
|
||||
description="Description 1"
|
||||
/>
|
||||
);
|
||||
|
||||
const { container: container2 } = render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Feature 2"
|
||||
description="Description 2"
|
||||
/>
|
||||
);
|
||||
|
||||
// Both should have SVG elements
|
||||
expect(container1.querySelector('svg')).toBeInTheDocument();
|
||||
expect(container2.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct icon size classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Time Feature"
|
||||
description="Time description"
|
||||
/>
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toHaveClass('h-6');
|
||||
expect(svgElement).toHaveClass('w-6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Colors', () => {
|
||||
it('should render with default brand color when no iconColor prop provided', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Default Color"
|
||||
description="Uses brand color by default"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-brand-100');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-brand-900/30');
|
||||
expect(iconWrapper).toHaveClass('text-brand-600');
|
||||
expect(iconWrapper).toHaveClass('dark:text-brand-400');
|
||||
});
|
||||
|
||||
it('should render with brand color when explicitly set', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Brand Color"
|
||||
description="Explicit brand color"
|
||||
iconColor="brand"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-brand-100');
|
||||
expect(iconWrapper).toHaveClass('text-brand-600');
|
||||
});
|
||||
|
||||
it('should render with green color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={CheckCircle}
|
||||
title="Success Feature"
|
||||
description="Green icon color"
|
||||
iconColor="green"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-green-100');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
|
||||
expect(iconWrapper).toHaveClass('text-green-600');
|
||||
expect(iconWrapper).toHaveClass('dark:text-green-400');
|
||||
});
|
||||
|
||||
it('should render with purple color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Purple Feature"
|
||||
description="Purple icon color"
|
||||
iconColor="purple"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-purple-100');
|
||||
expect(iconWrapper).toHaveClass('text-purple-600');
|
||||
});
|
||||
|
||||
it('should render with orange color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={AlertCircle}
|
||||
title="Warning Feature"
|
||||
description="Orange icon color"
|
||||
iconColor="orange"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-orange-100');
|
||||
expect(iconWrapper).toHaveClass('text-orange-600');
|
||||
});
|
||||
|
||||
it('should render with pink color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Pink Feature"
|
||||
description="Pink icon color"
|
||||
iconColor="pink"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-pink-100');
|
||||
expect(iconWrapper).toHaveClass('text-pink-600');
|
||||
});
|
||||
|
||||
it('should render with cyan color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Cyan Feature"
|
||||
description="Cyan icon color"
|
||||
iconColor="cyan"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-cyan-100');
|
||||
expect(iconWrapper).toHaveClass('text-cyan-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and CSS Classes', () => {
|
||||
it('should apply base card styling classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Card Styling"
|
||||
description="Testing base styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('group');
|
||||
expect(cardElement).toHaveClass('p-6');
|
||||
expect(cardElement).toHaveClass('bg-white');
|
||||
expect(cardElement).toHaveClass('dark:bg-gray-800');
|
||||
expect(cardElement).toHaveClass('rounded-2xl');
|
||||
});
|
||||
|
||||
it('should apply border classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Border Test"
|
||||
description="Testing border styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('border');
|
||||
expect(cardElement).toHaveClass('border-gray-200');
|
||||
expect(cardElement).toHaveClass('dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('should apply hover border classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Hover Border"
|
||||
description="Testing hover border styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('hover:border-brand-300');
|
||||
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
|
||||
});
|
||||
|
||||
it('should apply shadow classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Shadow Test"
|
||||
description="Testing shadow styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('hover:shadow-lg');
|
||||
expect(cardElement).toHaveClass('hover:shadow-brand-600/5');
|
||||
});
|
||||
|
||||
it('should apply transition classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Transition Test"
|
||||
description="Testing transition styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('transition-all');
|
||||
expect(cardElement).toHaveClass('duration-300');
|
||||
});
|
||||
|
||||
it('should apply icon wrapper styling', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Icon Wrapper"
|
||||
description="Testing icon wrapper styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('p-3');
|
||||
expect(iconWrapper).toHaveClass('rounded-xl');
|
||||
expect(iconWrapper).toHaveClass('mb-4');
|
||||
});
|
||||
|
||||
it('should apply title styling', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Title Styling"
|
||||
description="Testing title styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Title Styling');
|
||||
expect(titleElement).toHaveClass('text-lg');
|
||||
expect(titleElement).toHaveClass('font-semibold');
|
||||
expect(titleElement).toHaveClass('text-gray-900');
|
||||
expect(titleElement).toHaveClass('dark:text-white');
|
||||
expect(titleElement).toHaveClass('mb-2');
|
||||
});
|
||||
|
||||
it('should apply title hover classes', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Hover Title"
|
||||
description="Testing title hover styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Hover Title');
|
||||
expect(titleElement).toHaveClass('group-hover:text-brand-600');
|
||||
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
|
||||
expect(titleElement).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('should apply description styling', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Description Style"
|
||||
description="Testing description styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing description styles');
|
||||
expect(descriptionElement).toHaveClass('text-gray-600');
|
||||
expect(descriptionElement).toHaveClass('dark:text-gray-400');
|
||||
expect(descriptionElement).toHaveClass('leading-relaxed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hover and Animation States', () => {
|
||||
it('should have group class for hover effects', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Group Hover"
|
||||
description="Testing group hover functionality"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('group');
|
||||
});
|
||||
|
||||
it('should support mouse hover interactions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Mouse Hover"
|
||||
description="Testing mouse hover"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
|
||||
// Hovering should not cause errors
|
||||
await user.hover(cardElement);
|
||||
expect(cardElement).toBeInTheDocument();
|
||||
|
||||
// Unhovering should not cause errors
|
||||
await user.unhover(cardElement);
|
||||
expect(cardElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure during hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Structure Test"
|
||||
description="Testing structure during hover"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Structure Test');
|
||||
const descriptionElement = screen.getByText('Testing structure during hover');
|
||||
|
||||
// Hover over the card
|
||||
await user.hover(titleElement.closest('.group')!);
|
||||
|
||||
// Elements should still be present
|
||||
expect(titleElement).toBeInTheDocument();
|
||||
expect(descriptionElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic HTML heading for title', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Semantic Title"
|
||||
description="Testing semantic HTML"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Semantic Title');
|
||||
expect(titleElement.tagName).toBe('H3');
|
||||
});
|
||||
|
||||
it('should use paragraph element for description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Semantic Description"
|
||||
description="Testing paragraph element"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing paragraph element');
|
||||
expect(descriptionElement.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('should maintain readable text contrast', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Contrast Test"
|
||||
description="Testing text contrast"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Contrast Test');
|
||||
const descriptionElement = screen.getByText('Testing text contrast');
|
||||
|
||||
// Title should have dark text (gray-900)
|
||||
expect(titleElement).toHaveClass('text-gray-900');
|
||||
// Description should have readable gray
|
||||
expect(descriptionElement).toHaveClass('text-gray-600');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible when used in interactive context', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Keyboard Test"
|
||||
description="Testing keyboard accessibility"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
// Card itself is not interactive, so it shouldn't have tabIndex
|
||||
expect(cardElement).not.toHaveAttribute('tabIndex');
|
||||
});
|
||||
|
||||
it('should support screen readers with proper text hierarchy', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Screen Reader Test"
|
||||
description="This is a longer description that screen readers will announce."
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that heading comes before paragraph in DOM order
|
||||
const heading = container.querySelector('h3');
|
||||
const paragraph = container.querySelector('p');
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(paragraph).toBeInTheDocument();
|
||||
|
||||
// Verify DOM order (heading should appear before paragraph)
|
||||
const headingPosition = Array.from(container.querySelectorAll('*')).indexOf(heading!);
|
||||
const paragraphPosition = Array.from(container.querySelectorAll('*')).indexOf(paragraph!);
|
||||
expect(headingPosition).toBeLessThan(paragraphPosition);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for card background', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Card"
|
||||
description="Testing dark mode"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('dark:bg-gray-800');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for borders', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Border"
|
||||
description="Testing dark mode borders"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('dark:border-gray-700');
|
||||
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for title text', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Title"
|
||||
description="Testing dark mode title"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Dark Mode Title');
|
||||
expect(titleElement).toHaveClass('dark:text-white');
|
||||
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for description text', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Description"
|
||||
description="Testing dark mode description"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing dark mode description');
|
||||
expect(descriptionElement).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for icon colors', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Icon"
|
||||
description="Testing dark mode icon"
|
||||
iconColor="green"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
|
||||
expect(iconWrapper).toHaveClass('dark:text-green-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Props Validation', () => {
|
||||
it('should handle all required props', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Required Props"
|
||||
description="All required props provided"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
expect(screen.getByText('Required Props')).toBeInTheDocument();
|
||||
expect(screen.getByText('All required props provided')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle optional iconColor prop', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Optional Props"
|
||||
description="Optional iconColor provided"
|
||||
iconColor="purple"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-purple-100');
|
||||
});
|
||||
|
||||
it('should render correctly with minimal props', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Min Props"
|
||||
description="Minimal props"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very long title text', () => {
|
||||
const longTitle = 'This is a very long title that might wrap to multiple lines in the card';
|
||||
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title={longTitle}
|
||||
description="Normal description"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle special characters in title', () => {
|
||||
const specialTitle = 'Special <>&"\' Characters';
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title={specialTitle}
|
||||
description="Testing special chars"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(specialTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle special characters in description', () => {
|
||||
const specialDescription = 'Description with <>&"\' special characters';
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Special Chars"
|
||||
description={specialDescription}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Unicode Test 你好 🎉"
|
||||
description="Description with émojis and 中文"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Unicode Test 你好 🎉")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description with émojis and 中文")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user