Compare commits
87 Commits
434f874963
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbefccf436 | ||
|
|
e7733449dd | ||
|
|
29bcb27e76 | ||
|
|
41caccd31a | ||
|
|
aa9d920612 | ||
|
|
b384d9912a | ||
|
|
d25c578e59 | ||
|
|
a8c271b5e3 | ||
|
|
6afa3d7415 | ||
|
|
17786c5ec0 | ||
|
|
4a66246708 | ||
|
|
76c0d71aa0 | ||
|
|
384fe0fd86 | ||
|
|
4afcaa2b0d | ||
|
|
8c52d6a275 | ||
|
|
18c9a69d75 | ||
|
|
30ec150d90 | ||
|
|
ba2c656243 | ||
|
|
485f86086b | ||
|
|
2f6ea82114 | ||
|
|
507222316c | ||
|
|
c5c108c76f | ||
|
|
90fa628cb5 | ||
|
|
7f389830f8 | ||
|
|
30909f3268 | ||
|
|
df45a6f5d7 | ||
|
|
156ad09232 | ||
|
|
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 |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"WebSearch"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
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.
|
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
|
## CRITICAL: Backend Runs in Docker
|
||||||
|
|
||||||
**NEVER run Django commands directly.** Always use Docker Compose:
|
**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/api/client.ts` | Axios API client |
|
||||||
| `frontend/src/types.ts` | TypeScript interfaces |
|
| `frontend/src/types.ts` | TypeScript interfaces |
|
||||||
| `frontend/src/i18n/locales/en.json` | Translations |
|
| `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 |
|
| App | Location | Purpose |
|
||||||
|-----|----------|---------|
|
|-----|----------|---------|
|
||||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
|
||||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
| `users` | `identity/users/` | User model, authentication, MFA |
|
||||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
|
||||||
|
### 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
|
## 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)
|
||||||
647
README.md
647
README.md
@@ -1,257 +1,470 @@
|
|||||||
# SmoothSchedule - Multi-Tenant Scheduling Platform
|
# 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
|
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||||
- ✅ **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
|
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
|
||||||
- ✅ **Modern Stack**: Django 5.2 + React 18 + Vite
|
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
|
||||||
- ✅ **Docker Ready**: Complete production & development Docker Compose setup
|
- **Real-time Updates**: Django Channels + WebSockets
|
||||||
- ✅ **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
|
- **Background Tasks**: Celery + Redis
|
||||||
- ✅ **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
|
- **Auto SSL**: Let's Encrypt certificates via Traefik
|
||||||
- ✅ **Task Queue**: Celery + Redis for background jobs
|
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
|
||||||
- ✅ **Real-time**: Django Channels + WebSockets support
|
- **Docker Ready**: Complete Docker Compose setup for dev and production
|
||||||
- ✅ **Production Ready**: Fully configured for deployment
|
|
||||||
|
|
||||||
## 📚 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
|
smoothschedule2/
|
||||||
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
|
├── frontend/ # React + Vite + TypeScript
|
||||||
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
|
│ ├── src/
|
||||||
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
|
│ │ ├── 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
|
```bash
|
||||||
# Start backend (Django in Docker)
|
git clone https://github.com/your-repo/smoothschedule.git
|
||||||
cd smoothschedule
|
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
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
|
||||||
# Start frontend (React with Vite)
|
# Wait for services to initialize (first time takes longer)
|
||||||
cd ../frontend
|
sleep 30
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Access the app
|
# Run database migrations
|
||||||
# Frontend: http://platform.lvh.me:5173
|
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||||
# Backend API: http://lvh.me:8000/api
|
|
||||||
|
# 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.
|
### Step 3: Start the Frontend (React with Vite)
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to production server (code changes only)
|
cd ../frontend
|
||||||
./deploy.sh poduck@smoothschedule.com
|
|
||||||
|
# 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
|
### Multi-Tenancy Model
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
PostgreSQL Database
|
||||||
│ PostgreSQL Database │
|
├── public (shared schema)
|
||||||
├─────────────────────────────────────────┤
|
│ ├── Tenants
|
||||||
│ public (shared schema) │
|
│ ├── Domains
|
||||||
│ ├─ Tenants │
|
│ ├── Users
|
||||||
│ ├─ Domains │
|
│ └── PermissionGrants
|
||||||
│ ├─ Users │
|
├── demo (tenant schema)
|
||||||
│ └─ PermissionGrants │
|
│ ├── Resources
|
||||||
├─────────────────────────────────────────┤
|
│ ├── Events
|
||||||
│ tenant_demo (schema for Demo Company) │
|
│ ├── Services
|
||||||
│ ├─ Appointments │
|
│ └── Customers
|
||||||
│ ├─ Resources │
|
└── acme (tenant schema)
|
||||||
│ └─ Customers │
|
├── Resources
|
||||||
├─────────────────────────────────────────┤
|
├── Events
|
||||||
│ tenant_acme (schema for Acme Corp) │
|
└── ...
|
||||||
│ ├─ Appointments │
|
|
||||||
│ ├─ Resources │
|
|
||||||
│ └─ Customers │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Role Hierarchy
|
### Role Hierarchy
|
||||||
|
|
||||||
| Role | Level | Access Scope |
|
| Role | Level | Access |
|
||||||
|---------------------|----------|---------------------------|
|
|------|-------|--------|
|
||||||
| SUPERUSER | Platform | All tenants (god mode) |
|
| SUPERUSER | Platform | All tenants (god mode) |
|
||||||
| PLATFORM_MANAGER | Platform | All tenants |
|
| PLATFORM_MANAGER | Platform | All tenants |
|
||||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||||
| PLATFORM_SUPPORT | Platform | Tenant users |
|
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
|
||||||
| TENANT_OWNER | Tenant | Own tenant (full access) |
|
| TENANT_OWNER | Tenant | Full tenant access |
|
||||||
| TENANT_MANAGER | Tenant | Own tenant |
|
| TENANT_MANAGER | Tenant | Most tenant features |
|
||||||
| TENANT_STAFF | Tenant | Own tenant (limited) |
|
| TENANT_STAFF | Tenant | Limited tenant access |
|
||||||
| CUSTOMER | Tenant | Own data only |
|
| CUSTOMER | Tenant | Own data only |
|
||||||
|
|
||||||
### Masquerading Matrix
|
### Request Flow
|
||||||
|
|
||||||
| Hijacker Role | Can Masquerade As |
|
|
||||||
|--------------------|----------------------------------|
|
|
||||||
| SUPERUSER | Anyone |
|
|
||||||
| PLATFORM_SUPPORT | Tenant users |
|
|
||||||
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
|
|
||||||
| TENANT_OWNER | Staff in same tenant |
|
|
||||||
| Others | No one |
|
|
||||||
|
|
||||||
**Security Rules:**
|
|
||||||
- Cannot hijack yourself
|
|
||||||
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
|
|
||||||
- Maximum depth: 1 (no hijack chains)
|
|
||||||
- All attempts logged to `logs/masquerade.log`
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
smoothschedule/
|
Browser → Traefik (SSL) → nginx (frontend) or django (API)
|
||||||
├── config/
|
↓
|
||||||
│ └── settings.py # Multi-tenancy & security config
|
React SPA
|
||||||
├── core/
|
↓
|
||||||
│ ├── models.py # Tenant, Domain, PermissionGrant
|
/api/* → django:5000
|
||||||
│ ├── permissions.py # Hijack permission matrix
|
/ws/* → django:5000 (WebSocket)
|
||||||
│ ├── middleware.py # Masquerade audit logging
|
|
||||||
│ └── admin.py # Django admin for core models
|
|
||||||
├── users/
|
|
||||||
│ ├── models.py # Custom User with 8-tier roles
|
|
||||||
│ └── admin.py # User admin with hijack button
|
|
||||||
├── logs/
|
|
||||||
│ ├── security.log # General security events
|
|
||||||
│ └── masquerade.log # Hijack activity (JSON)
|
|
||||||
└── setup_project.sh # Automated setup script
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 Security Features
|
|
||||||
|
|
||||||
### Audit Logging
|
|
||||||
|
|
||||||
All masquerade activity is logged in JSON format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-01-15T10:30:00Z",
|
|
||||||
"action": "HIJACK_START",
|
|
||||||
"hijacker_email": "support@smoothschedule.com",
|
|
||||||
"hijacked_email": "customer@demo.com",
|
|
||||||
"ip_address": "192.168.1.1",
|
|
||||||
"session_key": "abc123..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission Grants (30-Minute Window)
|
|
||||||
|
|
||||||
Time-limited elevated permissions:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from core.models import PermissionGrant
|
|
||||||
|
|
||||||
grant = PermissionGrant.create_grant(
|
|
||||||
grantor=admin_user,
|
|
||||||
grantee=support_user,
|
|
||||||
action="view_billing",
|
|
||||||
reason="Customer requested billing support",
|
|
||||||
duration_minutes=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if active
|
|
||||||
if grant.is_active():
|
|
||||||
# Perform privileged action
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Testing Masquerading
|
|
||||||
|
|
||||||
1. Access Django Admin: `http://localhost:8000/admin/`
|
|
||||||
2. Create test users with different roles
|
|
||||||
3. Click "Hijack" button next to a user
|
|
||||||
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
|
|
||||||
|
|
||||||
## 📊 Admin Interface
|
|
||||||
|
|
||||||
- **Tenant Management**: View tenants, domains, subscription tiers
|
|
||||||
- **User Management**: Color-coded roles, masquerade buttons
|
|
||||||
- **Permission Grants**: Active/expired/revoked status, bulk revoke
|
|
||||||
- **Domain Verification**: AWS Route53 integration status
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
### Adding Tenant Apps
|
|
||||||
|
|
||||||
Edit `config/settings.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
TENANT_APPS = [
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'appointments', # Your app
|
|
||||||
'resources', # Your app
|
|
||||||
'billing', # Your app
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Domain Setup
|
|
||||||
|
|
||||||
```python
|
|
||||||
domain = Domain.objects.create(
|
|
||||||
domain="app.customdomain.com",
|
|
||||||
tenant=tenant,
|
|
||||||
is_custom_domain=True,
|
|
||||||
route53_zone_id="Z1234567890ABC",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then configure Route53 CNAME: `app.customdomain.com` → `smoothschedule.yourhost.com`
|
|
||||||
|
|
||||||
## 📖 Key Files Reference
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `setup_project.sh` | Automated project initialization |
|
|
||||||
| `config/settings.py` | Multi-tenancy, middleware, security config |
|
|
||||||
| `core/models.py` | Tenant, Domain, PermissionGrant models |
|
|
||||||
| `core/permissions.py` | Masquerading permission matrix |
|
|
||||||
| `core/middleware.py` | Audit logging for masquerading |
|
|
||||||
| `users/models.py` | Custom User with 8-tier roles |
|
|
||||||
|
|
||||||
## 📝 Important Notes
|
|
||||||
|
|
||||||
- **Django Admin**: The ONLY HTML interface (everything else is API)
|
|
||||||
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
|
|
||||||
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
|
|
||||||
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
**Cannot create tenant users:**
|
|
||||||
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
|
|
||||||
- Solution: Set `user.tenant = tenant_instance` before saving
|
|
||||||
|
|
||||||
**Hijack button doesn't appear:**
|
|
||||||
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
|
|
||||||
- Verify `HijackUserAdminMixin` in `users/admin.py`
|
|
||||||
- Ensure user has permission per matrix rules
|
|
||||||
|
|
||||||
**Migrations fail:**
|
|
||||||
- Run shared migrations first: `migrate_schemas --shared`
|
|
||||||
- Then run tenant migrations: `migrate_schemas`
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Built with ❤️ for multi-tenant SaaS perfection**
|
## 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
|
||||||
|
|||||||
68
deploy.sh
68
deploy.sh
@@ -1,7 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# SmoothSchedule Production Deployment Script
|
# SmoothSchedule Production Deployment Script
|
||||||
# Usage: ./deploy.sh [server_user@server_host]
|
# Usage: ./deploy.sh [server_user@server_host] [services...]
|
||||||
# Example: ./deploy.sh poduck@smoothschedule.com
|
# 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.
|
# This script deploys from git repository, not local files.
|
||||||
# Changes must be committed and pushed before deploying.
|
# Changes must be committed and pushed before deploying.
|
||||||
@@ -14,7 +19,23 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
SERVER=${1:-"poduck@smoothschedule.com"}
|
# 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"
|
REPO_URL="https://git.talova.net/poduck/smoothschedule.git"
|
||||||
REMOTE_DIR="/home/poduck/smoothschedule"
|
REMOTE_DIR="/home/poduck/smoothschedule"
|
||||||
|
|
||||||
@@ -22,6 +43,14 @@ echo -e "${GREEN}==================================="
|
|||||||
echo "SmoothSchedule Deployment"
|
echo "SmoothSchedule Deployment"
|
||||||
echo "===================================${NC}"
|
echo "===================================${NC}"
|
||||||
echo "Target server: $SERVER"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# Function to print status
|
# Function to print status
|
||||||
@@ -128,16 +157,25 @@ fi
|
|||||||
echo ">>> Current commit:"
|
echo ">>> Current commit:"
|
||||||
git log -1 --oneline
|
git log -1 --oneline
|
||||||
|
|
||||||
echo ">>> Building Docker images..."
|
|
||||||
cd smoothschedule
|
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
|
docker compose -f docker-compose.production.yml build
|
||||||
|
fi
|
||||||
|
|
||||||
echo ">>> Starting containers..."
|
echo ">>> Starting containers..."
|
||||||
docker compose -f docker-compose.production.yml up -d
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
|
||||||
echo ">>> Waiting for containers to start..."
|
echo ">>> Waiting for containers to start..."
|
||||||
sleep 10
|
sleep 5
|
||||||
|
|
||||||
|
# Run migrations unless skipped
|
||||||
|
if [[ "$SKIP_MIGRATE" != "true" ]]; then
|
||||||
echo ">>> Running database migrations..."
|
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'
|
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'
|
||||||
|
|
||||||
@@ -145,18 +183,20 @@ 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'
|
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 ">>> Seeding/updating platform plugins for all tenants..."
|
echo ">>> Seeding/updating platform plugins for all tenants..."
|
||||||
docker compose -f docker-compose.production.yml exec -T django python -c "
|
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_tenants.utils import get_tenant_model
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
Tenant = get_tenant_model()
|
Tenant = get_tenant_model()
|
||||||
for tenant in Tenant.objects.exclude(schema_name='public'):
|
for tenant in Tenant.objects.exclude(schema_name=\"public\"):
|
||||||
print(f' Seeding plugins for {tenant.schema_name}...')
|
print(f\" Seeding plugins for {tenant.schema_name}...\")
|
||||||
call_command('tenant_command', 'seed_platform_plugins', schema=tenant.schema_name, verbosity=0)
|
call_command(\"tenant_command\", \"seed_platform_plugins\", schema=tenant.schema_name, verbosity=0)
|
||||||
print(' Done!')
|
print(\" Done!\")
|
||||||
"
|
"'
|
||||||
|
else
|
||||||
echo ">>> Checking container status..."
|
echo ">>> Skipping migrations (--no-migrate flag used)"
|
||||||
docker compose -f docker-compose.production.yml ps
|
fi
|
||||||
|
|
||||||
echo ">>> Deployment complete!"
|
echo ">>> Deployment complete!"
|
||||||
ENDSSH
|
ENDSSH
|
||||||
|
|||||||
576
docs/SITE_BUILDER_DESIGN.md
Normal file
576
docs/SITE_BUILDER_DESIGN.md
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
# Puck Site Builder - Design Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the architecture, data model, migration strategy, and security decisions for the SmoothSchedule Puck-based site builder.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Production-quality site builder** - Enable tenants to build unique pages using nested layout primitives, theme tokens, and booking-native blocks
|
||||||
|
2. **Backward compatibility** - Existing pages must continue to render
|
||||||
|
3. **Multi-tenant safety** - Full tenant isolation for all page data
|
||||||
|
4. **Security** - No arbitrary script injection; sanitized embeds only
|
||||||
|
5. **Feature gating** - Hide/disable blocks based on plan without breaking existing content
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Current Schema (Existing)
|
||||||
|
|
||||||
|
```
|
||||||
|
Site
|
||||||
|
├── tenant (OneToOne → Tenant)
|
||||||
|
├── primary_domain
|
||||||
|
├── is_enabled
|
||||||
|
├── template_key
|
||||||
|
└── pages[] (Page)
|
||||||
|
|
||||||
|
Page
|
||||||
|
├── site (FK → Site)
|
||||||
|
├── slug
|
||||||
|
├── path
|
||||||
|
├── title
|
||||||
|
├── is_home
|
||||||
|
├── is_published
|
||||||
|
├── order
|
||||||
|
├── puck_data (JSONField - Puck Data payload)
|
||||||
|
└── version (int - for migrations)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Schema Additions
|
||||||
|
|
||||||
|
#### SiteConfig (New Model)
|
||||||
|
|
||||||
|
Stores global theme tokens and chrome settings. One per Site, not duplicated per page.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SiteConfig(models.Model):
|
||||||
|
site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name='config')
|
||||||
|
|
||||||
|
# Theme Tokens
|
||||||
|
theme = models.JSONField(default=dict)
|
||||||
|
# Structure:
|
||||||
|
# {
|
||||||
|
# "colors": {
|
||||||
|
# "primary": "#3b82f6",
|
||||||
|
# "secondary": "#64748b",
|
||||||
|
# "accent": "#f59e0b",
|
||||||
|
# "background": "#ffffff",
|
||||||
|
# "surface": "#f8fafc",
|
||||||
|
# "text": "#1e293b",
|
||||||
|
# "textMuted": "#64748b"
|
||||||
|
# },
|
||||||
|
# "typography": {
|
||||||
|
# "fontFamily": "Inter, system-ui, sans-serif",
|
||||||
|
# "headingFontFamily": null, # null = use fontFamily
|
||||||
|
# "baseFontSize": "16px",
|
||||||
|
# "scale": 1.25 # type scale ratio
|
||||||
|
# },
|
||||||
|
# "buttons": {
|
||||||
|
# "borderRadius": "8px",
|
||||||
|
# "primaryStyle": "solid", # solid | outline | ghost
|
||||||
|
# "secondaryStyle": "outline"
|
||||||
|
# },
|
||||||
|
# "sections": {
|
||||||
|
# "containerMaxWidth": "1280px",
|
||||||
|
# "defaultPaddingY": "80px"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Global Chrome
|
||||||
|
header = models.JSONField(default=dict)
|
||||||
|
# Structure:
|
||||||
|
# {
|
||||||
|
# "enabled": true,
|
||||||
|
# "logo": { "src": "", "alt": "", "width": 120 },
|
||||||
|
# "navigation": [
|
||||||
|
# { "label": "Home", "href": "/" },
|
||||||
|
# { "label": "Services", "href": "/services" },
|
||||||
|
# { "label": "Book Now", "href": "/book", "style": "button" }
|
||||||
|
# ],
|
||||||
|
# "sticky": true,
|
||||||
|
# "style": "default" # default | transparent | minimal
|
||||||
|
# }
|
||||||
|
|
||||||
|
footer = models.JSONField(default=dict)
|
||||||
|
# Structure:
|
||||||
|
# {
|
||||||
|
# "enabled": true,
|
||||||
|
# "columns": [
|
||||||
|
# {
|
||||||
|
# "title": "Company",
|
||||||
|
# "links": [{ "label": "About", "href": "/about" }]
|
||||||
|
# }
|
||||||
|
# ],
|
||||||
|
# "copyright": "© 2024 {business_name}. All rights reserved.",
|
||||||
|
# "socialLinks": [
|
||||||
|
# { "platform": "facebook", "url": "" },
|
||||||
|
# { "platform": "instagram", "url": "" }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
|
||||||
|
version = models.PositiveIntegerField(default=1)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page Model Enhancements
|
||||||
|
|
||||||
|
Add SEO and navigation fields to existing Page model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add to existing Page model:
|
||||||
|
meta_title = models.CharField(max_length=255, blank=True)
|
||||||
|
meta_description = models.TextField(blank=True)
|
||||||
|
og_image = models.URLField(blank=True)
|
||||||
|
canonical_url = models.URLField(blank=True)
|
||||||
|
noindex = models.BooleanField(default=False)
|
||||||
|
include_in_nav = models.BooleanField(default=True)
|
||||||
|
hide_chrome = models.BooleanField(default=False) # Landing page mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Puck Data Schema
|
||||||
|
|
||||||
|
The `puck_data` JSONField stores the Puck editor payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "Section",
|
||||||
|
"props": {
|
||||||
|
"id": "section-abc123",
|
||||||
|
"background": { "type": "color", "value": "#f8fafc" },
|
||||||
|
"padding": "large",
|
||||||
|
"containerWidth": "default",
|
||||||
|
"anchorId": "hero"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"root": {},
|
||||||
|
"zones": {
|
||||||
|
"section-abc123:content": [
|
||||||
|
{
|
||||||
|
"type": "Heading",
|
||||||
|
"props": { "text": "Welcome", "level": "h1" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Strategy
|
||||||
|
|
||||||
|
- `Page.version` tracks payload schema version
|
||||||
|
- `SiteConfig.version` tracks theme/chrome schema version
|
||||||
|
- Migrations are handled on read (lazy migration)
|
||||||
|
- On save, always write latest version
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Existing (No Changes)
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `GET /api/sites/me/` | GET | Get current site |
|
||||||
|
| `GET /api/sites/me/pages/` | GET | List pages |
|
||||||
|
| `POST /api/sites/me/pages/` | POST | Create page |
|
||||||
|
| `PATCH /api/sites/me/pages/{id}/` | PATCH | Update page |
|
||||||
|
| `DELETE /api/sites/me/pages/{id}/` | DELETE | Delete page |
|
||||||
|
| `GET /api/public/page/` | GET | Get home page (public) |
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `GET /api/sites/me/config/` | GET | Get site config (theme, chrome) |
|
||||||
|
| `PATCH /api/sites/me/config/` | PATCH | Update site config |
|
||||||
|
| `GET /api/public/page/{slug}/` | GET | Get page by slug (public) |
|
||||||
|
|
||||||
|
## Component Library
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
1. **Layout** - Section, Columns, Card, Spacer, Divider
|
||||||
|
2. **Content** - Heading, RichText, Image, Button, IconList, Testimonial, FAQ
|
||||||
|
3. **Booking** - BookingWidget, ServiceCatalog
|
||||||
|
4. **Contact** - ContactForm, BusinessHours, Map
|
||||||
|
|
||||||
|
### Component Specification
|
||||||
|
|
||||||
|
#### Section (Layout)
|
||||||
|
|
||||||
|
The fundamental building block for page sections.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "Section",
|
||||||
|
label: "Section",
|
||||||
|
fields: {
|
||||||
|
background: {
|
||||||
|
type: "custom", // Color picker, image upload, or gradient
|
||||||
|
options: ["none", "color", "image", "gradient"]
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
type: "custom", // Overlay color + opacity
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
type: "select",
|
||||||
|
options: ["none", "small", "medium", "large", "xlarge"]
|
||||||
|
},
|
||||||
|
containerWidth: {
|
||||||
|
type: "select",
|
||||||
|
options: ["narrow", "default", "wide", "full"]
|
||||||
|
},
|
||||||
|
anchorId: { type: "text" },
|
||||||
|
hideOnMobile: { type: "checkbox" },
|
||||||
|
hideOnTablet: { type: "checkbox" },
|
||||||
|
hideOnDesktop: { type: "checkbox" }
|
||||||
|
},
|
||||||
|
render: ({ puck }) => (
|
||||||
|
<section>
|
||||||
|
<div className={containerClass}>
|
||||||
|
<DropZone zone="content" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Columns (Layout)
|
||||||
|
|
||||||
|
Flexible column layout with nested drop zones.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "Columns",
|
||||||
|
fields: {
|
||||||
|
columns: {
|
||||||
|
type: "select",
|
||||||
|
options: ["2", "3", "4", "2-1", "1-2"] // ratios
|
||||||
|
},
|
||||||
|
gap: {
|
||||||
|
type: "select",
|
||||||
|
options: ["none", "small", "medium", "large"]
|
||||||
|
},
|
||||||
|
verticalAlign: {
|
||||||
|
type: "select",
|
||||||
|
options: ["top", "center", "bottom", "stretch"]
|
||||||
|
},
|
||||||
|
stackOnMobile: { type: "checkbox", default: true }
|
||||||
|
},
|
||||||
|
render: ({ columns, puck }) => (
|
||||||
|
<div className="grid">
|
||||||
|
{Array.from({ length: columnCount }).map((_, i) => (
|
||||||
|
<DropZone zone={`column-${i}`} key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BookingWidget (Booking)
|
||||||
|
|
||||||
|
Embedded booking interface - SmoothSchedule's differentiator.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "BookingWidget",
|
||||||
|
fields: {
|
||||||
|
serviceMode: {
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "All Services", value: "all" },
|
||||||
|
{ label: "By Category", value: "category" },
|
||||||
|
{ label: "Specific Services", value: "specific" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
categoryId: { type: "text" }, // When mode = category
|
||||||
|
serviceIds: { type: "array" }, // When mode = specific
|
||||||
|
showDuration: { type: "checkbox", default: true },
|
||||||
|
showPrice: { type: "checkbox", default: true },
|
||||||
|
showDeposits: { type: "checkbox", default: true },
|
||||||
|
requireLogin: { type: "checkbox", default: false },
|
||||||
|
ctaAfterBooking: { type: "text" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Measures
|
||||||
|
|
||||||
|
### 1. XSS Prevention
|
||||||
|
|
||||||
|
All text content is rendered through React, which auto-escapes HTML by default.
|
||||||
|
|
||||||
|
For rich text (RichText component):
|
||||||
|
- Store content as structured JSON (Slate/Tiptap document format), not raw HTML
|
||||||
|
- Render using a safe renderer that only supports whitelisted elements (p, strong, em, a, ul, ol, li)
|
||||||
|
- Never render raw HTML strings directly into the DOM
|
||||||
|
- All user-provided content goes through React's safe text rendering
|
||||||
|
|
||||||
|
### 2. Embed/Script Injection
|
||||||
|
|
||||||
|
No arbitrary embeds allowed. Map component only supports:
|
||||||
|
- Google Maps embed URLs (maps.google.com/*)
|
||||||
|
- OpenStreetMap iframes
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
```typescript
|
||||||
|
const ALLOWED_EMBED_DOMAINS = [
|
||||||
|
'www.google.com/maps/embed',
|
||||||
|
'maps.google.com',
|
||||||
|
'www.openstreetmap.org'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAllowedEmbed(url: string): boolean {
|
||||||
|
return ALLOWED_EMBED_DOMAINS.some(domain =>
|
||||||
|
url.startsWith(`https://${domain}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Backend Validation
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In PageSerializer.validate_puck_data()
|
||||||
|
def validate_puck_data(self, value):
|
||||||
|
# 1. Size limit
|
||||||
|
if len(json.dumps(value)) > 5_000_000: # 5MB limit
|
||||||
|
raise ValidationError("Page data too large")
|
||||||
|
|
||||||
|
# 2. Validate structure
|
||||||
|
if not isinstance(value.get('content'), list):
|
||||||
|
raise ValidationError("Invalid puck_data structure")
|
||||||
|
|
||||||
|
# 3. Scan for disallowed content
|
||||||
|
serialized = json.dumps(value).lower()
|
||||||
|
disallowed = ['<script', 'javascript:', 'onerror=', 'onload=']
|
||||||
|
for pattern in disallowed:
|
||||||
|
if pattern in serialized:
|
||||||
|
raise ValidationError("Disallowed content detected")
|
||||||
|
|
||||||
|
return value
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Tenant Isolation
|
||||||
|
|
||||||
|
All queries are automatically tenant-scoped:
|
||||||
|
- `get_queryset()` filters by `site__tenant=request.tenant`
|
||||||
|
- `perform_create()` assigns site from tenant context
|
||||||
|
- No cross-tenant data access possible
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Current State Analysis
|
||||||
|
|
||||||
|
The existing implementation already uses Puck with 3 components:
|
||||||
|
- Hero
|
||||||
|
- TextSection
|
||||||
|
- Booking
|
||||||
|
|
||||||
|
No "enum-based component list" migration needed - the system is already Puck-native.
|
||||||
|
|
||||||
|
### Forward Migration
|
||||||
|
|
||||||
|
When adding new component types or changing prop schemas:
|
||||||
|
|
||||||
|
1. **Version field** tracks schema version per page
|
||||||
|
2. **Lazy migration** on read - transform old format to new
|
||||||
|
3. **Save updates version** - always writes latest format
|
||||||
|
|
||||||
|
Example migration:
|
||||||
|
```typescript
|
||||||
|
// v1 → v2: Hero.align was string, now object with breakpoint values
|
||||||
|
function migrateHeroV1toV2(props: any): any {
|
||||||
|
if (typeof props.align === 'string') {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
align: {
|
||||||
|
mobile: 'center',
|
||||||
|
tablet: props.align,
|
||||||
|
desktop: props.align
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Registry
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const MIGRATIONS: Record<number, (data: PuckData) => PuckData> = {
|
||||||
|
2: migrateV1toV2,
|
||||||
|
3: migrateV2toV3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function migratePuckData(data: PuckData, currentVersion: number): PuckData {
|
||||||
|
let migrated = data;
|
||||||
|
for (let v = currentVersion + 1; v <= LATEST_VERSION; v++) {
|
||||||
|
if (MIGRATIONS[v]) {
|
||||||
|
migrated = MIGRATIONS[v](migrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Gating
|
||||||
|
|
||||||
|
### Plan-Based Component Access
|
||||||
|
|
||||||
|
Some components are gated by plan features:
|
||||||
|
- **ContactForm** - requires `can_use_contact_form` feature
|
||||||
|
- **ServiceCatalog** - requires `can_use_service_catalog` feature
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. **Config generation** passes feature flags to frontend:
|
||||||
|
```typescript
|
||||||
|
function getComponentConfig(features: Features): Config {
|
||||||
|
const components = { ...baseComponents };
|
||||||
|
|
||||||
|
if (!features.can_use_contact_form) {
|
||||||
|
delete components.ContactForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { components };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rendering** always includes all component renderers:
|
||||||
|
```typescript
|
||||||
|
// Full config for rendering (never gated)
|
||||||
|
const renderConfig = { components: allComponents };
|
||||||
|
|
||||||
|
// Gated config for editing
|
||||||
|
const editorConfig = getComponentConfig(features);
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures pages with gated components still render correctly, even if the user can't add new instances.
|
||||||
|
|
||||||
|
## Editor UX Enhancements
|
||||||
|
|
||||||
|
### Viewport Toggles
|
||||||
|
|
||||||
|
Desktop (default), Tablet (768px), Mobile (375px)
|
||||||
|
|
||||||
|
### Outline Navigation
|
||||||
|
|
||||||
|
Tree view of page structure with:
|
||||||
|
- Drag to reorder
|
||||||
|
- Click to select
|
||||||
|
- Collapse/expand zones
|
||||||
|
|
||||||
|
### Categorized Component Palette
|
||||||
|
|
||||||
|
- Layout: Section, Columns, Card, Spacer, Divider
|
||||||
|
- Content: Heading, RichText, Image, Button, IconList, Testimonial, FAQ
|
||||||
|
- Booking: BookingWidget, ServiceCatalog
|
||||||
|
- Contact: ContactForm, BusinessHours, Map
|
||||||
|
|
||||||
|
### Page Settings Panel
|
||||||
|
|
||||||
|
Accessible via page icon in header:
|
||||||
|
- Title & slug
|
||||||
|
- Meta title & description
|
||||||
|
- OG image
|
||||||
|
- Canonical URL
|
||||||
|
- Index/noindex toggle
|
||||||
|
- Include in navigation toggle
|
||||||
|
- Hide chrome toggle (landing page mode)
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
smoothschedule/platform/tenant_sites/
|
||||||
|
├── models.py # Site, SiteConfig, Page, Domain
|
||||||
|
├── serializers.py # API serializers with validation
|
||||||
|
├── views.py # ViewSets and API views
|
||||||
|
├── validators.py # Puck data validation helpers
|
||||||
|
├── migrations/
|
||||||
|
│ └── 0002_siteconfig_page_seo_fields.py
|
||||||
|
└── tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── test_models.py
|
||||||
|
├── test_serializers.py
|
||||||
|
├── test_views.py
|
||||||
|
└── test_tenant_isolation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── puck/
|
||||||
|
│ ├── config.ts # Main Puck config export
|
||||||
|
│ ├── types.ts # Component prop types
|
||||||
|
│ ├── migrations.ts # Data migration functions
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ │ ├── Section.tsx
|
||||||
|
│ │ │ ├── Columns.tsx
|
||||||
|
│ │ │ ├── Card.tsx
|
||||||
|
│ │ │ ├── Spacer.tsx
|
||||||
|
│ │ │ └── Divider.tsx
|
||||||
|
│ │ ├── content/
|
||||||
|
│ │ │ ├── Heading.tsx
|
||||||
|
│ │ │ ├── RichText.tsx
|
||||||
|
│ │ │ ├── Image.tsx
|
||||||
|
│ │ │ ├── Button.tsx
|
||||||
|
│ │ │ ├── IconList.tsx
|
||||||
|
│ │ │ ├── Testimonial.tsx
|
||||||
|
│ │ │ └── FAQ.tsx
|
||||||
|
│ │ ├── booking/
|
||||||
|
│ │ │ ├── BookingWidget.tsx
|
||||||
|
│ │ │ └── ServiceCatalog.tsx
|
||||||
|
│ │ └── contact/
|
||||||
|
│ │ ├── ContactForm.tsx
|
||||||
|
│ │ ├── BusinessHours.tsx
|
||||||
|
│ │ └── Map.tsx
|
||||||
|
│ └── fields/
|
||||||
|
│ ├── ColorPicker.tsx
|
||||||
|
│ ├── BackgroundPicker.tsx
|
||||||
|
│ └── RichTextEditor.tsx
|
||||||
|
├── pages/
|
||||||
|
│ ├── PageEditor.tsx # Enhanced editor
|
||||||
|
│ └── PublicPage.tsx # Public renderer
|
||||||
|
├── hooks/
|
||||||
|
│ └── useSites.ts # Site/Page/Config hooks
|
||||||
|
└── __tests__/
|
||||||
|
└── puck/
|
||||||
|
├── migrations.test.ts
|
||||||
|
├── components.test.tsx
|
||||||
|
└── config.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Tests First** (TDD)
|
||||||
|
- Backend: tenant isolation, CRUD, validation
|
||||||
|
- Frontend: migration, rendering, feature gating
|
||||||
|
|
||||||
|
2. **Data Model**
|
||||||
|
- Add SiteConfig model
|
||||||
|
- Add Page SEO fields
|
||||||
|
- Create migrations
|
||||||
|
|
||||||
|
3. **API**
|
||||||
|
- SiteConfig endpoints
|
||||||
|
- Enhanced PageSerializer validation
|
||||||
|
|
||||||
|
4. **Components**
|
||||||
|
- Layout primitives (Section, Columns)
|
||||||
|
- Content blocks (Heading, RichText, Image)
|
||||||
|
- Booking blocks (enhanced BookingWidget)
|
||||||
|
- Contact blocks (ContactForm, BusinessHours)
|
||||||
|
|
||||||
|
5. **Editor**
|
||||||
|
- Viewport toggles
|
||||||
|
- Categorized palette
|
||||||
|
- Page settings panel
|
||||||
|
|
||||||
|
6. **Public Rendering**
|
||||||
|
- Apply theme tokens
|
||||||
|
- Render header/footer chrome
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
VITE_DEV_MODE=true
|
VITE_DEV_MODE=true
|
||||||
VITE_API_URL=http://api.lvh.me:8000
|
VITE_API_URL=http://api.lvh.me:8000
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51Sa2i4G4IkZ6cJFI77f9dXf1ljmDPAInxbjLCJRRJk4ng1qmJKtWEqkFcDuoVcAdQsxcMH1L1UiQFfPwy8OmLSaz008GsGQ63y
|
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
|
├── frontend/ # This React frontend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/client.ts # Axios API client
|
│ │ ├── 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.)
|
│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.)
|
||||||
│ │ ├── pages/ # Page components
|
│ │ ├── pages/ # Page components
|
||||||
│ │ ├── types.ts # TypeScript interfaces
|
│ │ ├── types.ts # TypeScript interfaces
|
||||||
@@ -31,6 +34,125 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
|
|||||||
└── users/ # User management
|
└── 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
|
## Local Development Domain Setup
|
||||||
|
|
||||||
### Why lvh.me instead of localhost?
|
### Why lvh.me instead of localhost?
|
||||||
|
|||||||
@@ -2,10 +2,31 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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. -->
|
<!-- 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">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
/* Ensure full height for the app */
|
/* Ensure full height for the app */
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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)
|
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
|
||||||
location /static/ {
|
location /static/ {
|
||||||
proxy_pass http://django:5000;
|
proxy_pass http://django:5000;
|
||||||
|
|||||||
1646
frontend/package-lock.json
generated
1646
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@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/connect-js": "^3.3.31",
|
||||||
"@stripe/react-connect-js": "^3.3.31",
|
"@stripe/react-connect-js": "^3.3.31",
|
||||||
"@stripe/react-stripe-js": "^5.4.1",
|
"@stripe/react-stripe-js": "^5.4.1",
|
||||||
@@ -34,27 +36,38 @@
|
|||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.6",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.0.15"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test",
|
"test": "vitest",
|
||||||
"test:ui": "playwright test --ui",
|
"test:run": "vitest run",
|
||||||
"test:headed": "playwright test --headed"
|
"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
|
# robots.txt - SmoothSchedule
|
||||||
# Deny all robots while in development
|
# Currently blocking all crawlers - site not yet live
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /
|
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 { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||||
|
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
||||||
import { setCookie } from './utils/cookies';
|
import { setCookie } from './utils/cookies';
|
||||||
|
|
||||||
// Import Login Page
|
// Import Login Page
|
||||||
@@ -34,21 +35,28 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
|||||||
|
|
||||||
// Import pages
|
// Import pages
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
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 Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||||
const Settings = React.lazy(() => import('./pages/Settings'));
|
const Settings = React.lazy(() => import('./pages/Settings'));
|
||||||
const Payments = React.lazy(() => import('./pages/Payments'));
|
const Payments = React.lazy(() => import('./pages/Payments'));
|
||||||
|
const Messages = React.lazy(() => import('./pages/Messages'));
|
||||||
const Resources = React.lazy(() => import('./pages/Resources'));
|
const Resources = React.lazy(() => import('./pages/Resources'));
|
||||||
const Services = React.lazy(() => import('./pages/Services'));
|
const Services = React.lazy(() => import('./pages/Services'));
|
||||||
const Staff = React.lazy(() => import('./pages/Staff'));
|
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 CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDashboard'));
|
||||||
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
||||||
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
||||||
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
||||||
|
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
|
||||||
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
||||||
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
||||||
|
|
||||||
// Import platform pages
|
// Import platform pages
|
||||||
|
const PlatformLoginPage = React.lazy(() => import('./pages/platform/PlatformLoginPage'));
|
||||||
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
|
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
|
||||||
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
|
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
|
||||||
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
|
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
|
||||||
@@ -56,11 +64,13 @@ const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/Platfor
|
|||||||
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
|
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
|
||||||
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
||||||
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||||
|
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
|
||||||
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
|
||||||
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
|
||||||
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
|
||||||
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
|
||||||
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
|
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 Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
|
||||||
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide 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 HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
||||||
@@ -76,8 +86,10 @@ const HelpCustomers = React.lazy(() => import('./pages/help/HelpCustomers'));
|
|||||||
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
|
const HelpServices = React.lazy(() => import('./pages/help/HelpServices'));
|
||||||
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
|
const HelpResources = React.lazy(() => import('./pages/help/HelpResources'));
|
||||||
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
|
const HelpStaff = React.lazy(() => import('./pages/help/HelpStaff'));
|
||||||
|
const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
|
||||||
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
||||||
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
|
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 HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
|
||||||
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
||||||
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
||||||
@@ -90,12 +102,21 @@ const HelpSettingsAuth = React.lazy(() => import('./pages/help/HelpSettingsAuth'
|
|||||||
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
const HelpSettingsBilling = React.lazy(() => import('./pages/help/HelpSettingsBilling'));
|
||||||
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuota'));
|
||||||
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
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 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 PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||||
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin 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 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 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
|
||||||
|
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
||||||
|
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
|
||||||
|
const MediaGalleryPage = React.lazy(() => import('./pages/MediaGalleryPage')); // Import Media Gallery page
|
||||||
|
|
||||||
// Settings pages
|
// Settings pages
|
||||||
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
|
||||||
@@ -110,6 +131,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
|
|||||||
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||||
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||||
|
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
||||||
|
|
||||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||||
|
|
||||||
@@ -183,6 +205,7 @@ const AppContent: React.FC = () => {
|
|||||||
const updateBusinessMutation = useUpdateBusiness();
|
const updateBusinessMutation = useUpdateBusiness();
|
||||||
const masqueradeMutation = useMasquerade();
|
const masqueradeMutation = useMasquerade();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
// Apply dark mode class and persist to localStorage
|
// Apply dark mode class and persist to localStorage
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -190,6 +213,30 @@ const AppContent: React.FC = () => {
|
|||||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
}, [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)
|
// Handle tokens in URL (from login or masquerade redirect)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -281,35 +328,70 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||||
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not authenticated - redirect to root domain for login if on subdomain
|
// Not authenticated - show appropriate page based on subdomain
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// If on a subdomain, redirect to root domain login page
|
|
||||||
const currentHostname = window.location.hostname;
|
const currentHostname = window.location.hostname;
|
||||||
const hostnameParts = currentHostname.split('.');
|
const hostnameParts = currentHostname.split('.');
|
||||||
const baseDomain = hostnameParts.length >= 2
|
const baseDomain = hostnameParts.length >= 2
|
||||||
? hostnameParts.slice(-2).join('.')
|
? hostnameParts.slice(-2).join('.')
|
||||||
: currentHostname;
|
: currentHostname;
|
||||||
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
|
const isRootDomainForUnauthUser = currentHostname === baseDomain || currentHostname === 'localhost';
|
||||||
|
const isPlatformSubdomain = hostnameParts[0] === 'platform';
|
||||||
|
const currentSubdomain = hostnameParts[0];
|
||||||
|
|
||||||
// Don't redirect for certain public paths that should work on any subdomain
|
// Check if we're on a business subdomain (not root, not platform, not api)
|
||||||
const publicPaths = ['/accept-invite', '/verify-email', '/tenant-onboard'];
|
const isBusinessSubdomain = !isRootDomainForUnauthUser && !isPlatformSubdomain && currentSubdomain !== 'api';
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
const isPublicPath = publicPaths.some(path => currentPath.startsWith(path));
|
|
||||||
|
|
||||||
if (!isRootDomainForUnauthUser && !isPublicPath) {
|
// For business subdomains, show the tenant landing page with login option
|
||||||
// Redirect to root domain login (preserve port)
|
if (isBusinessSubdomain) {
|
||||||
const protocol = window.location.protocol;
|
return (
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
<Routes>
|
||||||
return <LoadingScreen />;
|
<Route path="/" element={<PublicPage />} />
|
||||||
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
|
<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 platform subdomain, only /platform/login exists - everything else renders nothing
|
||||||
|
if (isPlatformSubdomain) {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
|
||||||
|
|
||||||
|
// If not an allowed path, render nothing
|
||||||
|
if (!allowedPaths.includes(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/platform/login" element={<PlatformLoginPage />} />
|
||||||
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For root domain, show marketing site with business user login
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -330,6 +412,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
|
||||||
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
|
||||||
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -437,7 +520,10 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||||
<Route path="/help/email" element={<HelpEmailSettings />} />
|
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||||
{user.role === 'superuser' && (
|
{user.role === 'superuser' && (
|
||||||
|
<>
|
||||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||||
|
<Route path="/platform/billing" element={<BillingManagement />} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
@@ -501,7 +587,7 @@ const AppContent: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Route path="/" element={<CustomerDashboard />} />
|
<Route path="/" element={<CustomerDashboard />} />
|
||||||
<Route path="/book" element={<BookingPage />} />
|
<Route path="/book" element={<BookingPage />} />
|
||||||
<Route path="/payments" element={<Payments />} />
|
<Route path="/payments" element={<CustomerBilling />} />
|
||||||
<Route path="/support" element={<CustomerSupport />} />
|
<Route path="/support" element={<CustomerSupport />} />
|
||||||
<Route path="/profile" element={<ProfileSettings />} />
|
<Route path="/profile" element={<ProfileSettings />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
@@ -571,7 +657,7 @@ const AppContent: React.FC = () => {
|
|||||||
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
||||||
|
|
||||||
// Allowed routes when trial is expired
|
// Allowed routes when trial is expired
|
||||||
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
|
const allowedWhenExpired = ['/dashboard/trial-expired', '/dashboard/upgrade', '/dashboard/settings', '/dashboard/profile'];
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
||||||
|
|
||||||
@@ -580,15 +666,15 @@ const AppContent: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
|
||||||
<Route path="/upgrade" element={<Upgrade />} />
|
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
||||||
<Route path="/profile" element={<ProfileSettings />} />
|
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
||||||
{/* Trial-expired users can access billing settings to upgrade */}
|
{/* Trial-expired users can access billing settings to upgrade */}
|
||||||
<Route
|
<Route
|
||||||
path="/settings/*"
|
path="/dashboard/settings/*"
|
||||||
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
|
element={hasAccess(['owner']) ? <Navigate to="/dashboard/upgrade" /> : <Navigate to="/dashboard/trial-expired" />}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard/trial-expired" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -597,6 +683,13 @@ const AppContent: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingScreen />}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public routes outside BusinessLayout */}
|
||||||
|
<Route path="/" element={<PublicPage />} />
|
||||||
|
<Route path="/book" element={<BookingFlow />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/sign/:token" element={<ContractSigning />} />
|
||||||
|
|
||||||
|
{/* Dashboard routes inside BusinessLayout */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<BusinessLayout
|
<BusinessLayout
|
||||||
@@ -610,161 +703,251 @@ const AppContent: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Trial and Upgrade Routes */}
|
{/* Trial and Upgrade Routes */}
|
||||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
|
||||||
<Route path="/upgrade" element={<Upgrade />} />
|
<Route path="/dashboard/upgrade" element={<Upgrade />} />
|
||||||
|
|
||||||
{/* Regular Routes */}
|
{/* Regular Routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/dashboard"
|
||||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||||
/>
|
/>
|
||||||
<Route path="/scheduler" element={<Scheduler />} />
|
{/* Staff Schedule - vertical timeline view */}
|
||||||
<Route path="/tickets" element={<Tickets />} />
|
|
||||||
<Route path="/help" element={<HelpComprehensive />} />
|
|
||||||
<Route path="/help/guide" element={<HelpGuide />} />
|
|
||||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
|
||||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
|
||||||
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
|
|
||||||
<Route path="/help/email" element={<HelpEmailSettings />} />
|
|
||||||
{/* 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/messages" element={<HelpMessages />} />
|
|
||||||
<Route path="/help/payments" element={<HelpPayments />} />
|
|
||||||
<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
|
<Route
|
||||||
path="/plugins/marketplace"
|
path="/dashboard/my-schedule"
|
||||||
|
element={
|
||||||
|
hasAccess(['staff']) ? (
|
||||||
|
<StaffSchedule user={user} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/dashboard/scheduler" element={<Scheduler />} />
|
||||||
|
<Route path="/dashboard/tickets" element={<Tickets />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard/help"
|
||||||
|
element={
|
||||||
|
user.role === 'staff' ? (
|
||||||
|
<StaffHelp user={user} />
|
||||||
|
) : (
|
||||||
|
<HelpComprehensive />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
|
||||||
|
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
|
||||||
|
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
|
||||||
|
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
|
||||||
|
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
|
||||||
|
{/* New help pages */}
|
||||||
|
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
|
||||||
|
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
|
||||||
|
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
|
||||||
|
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
|
||||||
|
<Route path="/dashboard/help/services" element={<HelpServices />} />
|
||||||
|
<Route path="/dashboard/help/resources" element={<HelpResources />} />
|
||||||
|
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
|
||||||
|
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
|
||||||
|
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
|
||||||
|
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
|
||||||
|
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
||||||
|
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
|
||||||
|
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
|
||||||
|
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
||||||
|
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
|
||||||
|
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
|
||||||
|
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
|
||||||
|
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
|
||||||
|
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
|
||||||
|
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
|
||||||
|
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
|
||||||
|
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard/plugins/marketplace"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<PluginMarketplace />
|
<PluginMarketplace />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/plugins/my-plugins"
|
path="/dashboard/plugins/my-plugins"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<MyPlugins />
|
<MyPlugins />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/plugins/create"
|
path="/dashboard/plugins/create"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<CreatePlugin />
|
<CreatePlugin />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/tasks"
|
path="/dashboard/tasks"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Tasks />
|
<Tasks />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/email-templates"
|
path="/dashboard/email-templates"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<EmailTemplates />
|
<EmailTemplates />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/support" element={<PlatformSupport />} />
|
<Route path="/dashboard/support" element={<PlatformSupport />} />
|
||||||
<Route
|
<Route
|
||||||
path="/customers"
|
path="/dashboard/customers"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/services"
|
path="/dashboard/services"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Services />
|
<Services />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/resources"
|
path="/dashboard/resources"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/staff"
|
path="/dashboard/staff"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/payments"
|
path="/dashboard/time-blocks"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
hasAccess(['owner', 'manager']) ? (
|
||||||
|
<TimeBlocks />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/messages"
|
path="/dashboard/locations"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) ? (
|
||||||
<div className="p-8">
|
<Locations />
|
||||||
<h1 className="text-2xl font-bold mb-4">Messages</h1>
|
|
||||||
<p className="text-gray-600">Messages feature coming soon...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/my-availability"
|
||||||
|
element={
|
||||||
|
hasAccess(['staff', 'resource']) ? (
|
||||||
|
<MyAvailability user={user} />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/contracts"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||||
|
<Contracts />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/contracts/templates"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||||
|
<ContractTemplates />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/payments"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/messages"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
|
||||||
|
<Messages />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/site-editor"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) ? (
|
||||||
|
<PageEditor />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/gallery"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) ? (
|
||||||
|
<MediaGalleryPage />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Settings Routes with Nested Layout */}
|
{/* Settings Routes with Nested Layout */}
|
||||||
{hasAccess(['owner']) ? (
|
{hasAccess(['owner']) ? (
|
||||||
<Route path="/settings" element={<SettingsLayout />}>
|
<Route path="/dashboard/settings" element={<SettingsLayout />}>
|
||||||
<Route index element={<Navigate to="/settings/general" replace />} />
|
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
|
||||||
<Route path="general" element={<GeneralSettings />} />
|
<Route path="general" element={<GeneralSettings />} />
|
||||||
<Route path="branding" element={<BrandingSettings />} />
|
<Route path="branding" element={<BrandingSettings />} />
|
||||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||||
<Route path="booking" element={<BookingSettings />} />
|
<Route path="booking" element={<BookingSettings />} />
|
||||||
|
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||||
<Route path="email-templates" element={<EmailTemplates />} />
|
<Route path="email-templates" element={<EmailTemplates />} />
|
||||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||||
<Route path="api" element={<ApiSettings />} />
|
<Route path="api" element={<ApiSettings />} />
|
||||||
@@ -775,12 +958,14 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="quota" element={<QuotaSettings />} />
|
<Route path="quota" element={<QuotaSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
<Route path="/settings/*" element={<Navigate to="/" />} />
|
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
|
||||||
)}
|
)}
|
||||||
<Route path="/profile" element={<ProfileSettings />} />
|
<Route path="/dashboard/profile" element={<ProfileSettings />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Catch-all redirects to home */}
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
214
frontend/src/api/__tests__/billing.test.ts
Normal file
214
frontend/src/api/__tests__/billing.test.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Billing API client functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import apiClient from '../client';
|
||||||
|
import {
|
||||||
|
getEntitlements,
|
||||||
|
getCurrentSubscription,
|
||||||
|
getPlans,
|
||||||
|
getAddOns,
|
||||||
|
getInvoices,
|
||||||
|
getInvoice,
|
||||||
|
Entitlements,
|
||||||
|
Subscription,
|
||||||
|
PlanVersion,
|
||||||
|
AddOnProduct,
|
||||||
|
Invoice,
|
||||||
|
} from '../billing';
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
vi.mock('../client', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Billing API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEntitlements', () => {
|
||||||
|
it('fetches entitlements from /api/me/entitlements/', async () => {
|
||||||
|
const mockEntitlements: Entitlements = {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
max_users: 10,
|
||||||
|
max_resources: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
|
||||||
|
|
||||||
|
const result = await getEntitlements();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
|
||||||
|
expect(result).toEqual(mockEntitlements);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object on error', async () => {
|
||||||
|
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await getEntitlements();
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentSubscription', () => {
|
||||||
|
it('fetches subscription from /api/me/subscription/', async () => {
|
||||||
|
const mockSubscription: Subscription = {
|
||||||
|
id: 1,
|
||||||
|
status: 'active',
|
||||||
|
plan_version: {
|
||||||
|
id: 10,
|
||||||
|
name: 'Pro Plan v1',
|
||||||
|
is_legacy: false,
|
||||||
|
plan: { code: 'pro', name: 'Pro' },
|
||||||
|
price_monthly_cents: 7900,
|
||||||
|
price_yearly_cents: 79000,
|
||||||
|
},
|
||||||
|
current_period_start: '2024-01-01T00:00:00Z',
|
||||||
|
current_period_end: '2024-02-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
|
||||||
|
|
||||||
|
const result = await getCurrentSubscription();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
|
||||||
|
expect(result).toEqual(mockSubscription);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no subscription (404)', async () => {
|
||||||
|
const error = { response: { status: 404 } };
|
||||||
|
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await getCurrentSubscription();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlans', () => {
|
||||||
|
it('fetches public plans from /api/billing/plans/', async () => {
|
||||||
|
const mockPlans: PlanVersion[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Free Plan',
|
||||||
|
is_legacy: false,
|
||||||
|
is_public: true,
|
||||||
|
plan: { code: 'free', name: 'Free' },
|
||||||
|
price_monthly_cents: 0,
|
||||||
|
price_yearly_cents: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Pro Plan',
|
||||||
|
is_legacy: false,
|
||||||
|
is_public: true,
|
||||||
|
plan: { code: 'pro', name: 'Pro' },
|
||||||
|
price_monthly_cents: 7900,
|
||||||
|
price_yearly_cents: 79000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
|
||||||
|
|
||||||
|
const result = await getPlans();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
|
||||||
|
expect(result).toEqual(mockPlans);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAddOns', () => {
|
||||||
|
it('fetches active add-ons from /api/billing/addons/', async () => {
|
||||||
|
const mockAddOns: AddOnProduct[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'sms_pack',
|
||||||
|
name: 'SMS Pack',
|
||||||
|
price_monthly_cents: 500,
|
||||||
|
price_one_time_cents: 0,
|
||||||
|
is_stackable: false,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
|
||||||
|
|
||||||
|
const result = await getAddOns();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
|
||||||
|
expect(result).toEqual(mockAddOns);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInvoices', () => {
|
||||||
|
it('fetches invoices from /api/billing/invoices/', async () => {
|
||||||
|
const mockInvoices: Invoice[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'paid',
|
||||||
|
period_start: '2024-01-01T00:00:00Z',
|
||||||
|
period_end: '2024-02-01T00:00:00Z',
|
||||||
|
subtotal_amount: 7900,
|
||||||
|
total_amount: 7900,
|
||||||
|
plan_name_at_billing: 'Pro Plan',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
|
||||||
|
|
||||||
|
const result = await getInvoices();
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
|
||||||
|
expect(result).toEqual(mockInvoices);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInvoice', () => {
|
||||||
|
it('fetches a single invoice by ID', async () => {
|
||||||
|
const mockInvoice: Invoice = {
|
||||||
|
id: 1,
|
||||||
|
status: 'paid',
|
||||||
|
period_start: '2024-01-01T00:00:00Z',
|
||||||
|
period_end: '2024-02-01T00:00:00Z',
|
||||||
|
subtotal_amount: 7900,
|
||||||
|
total_amount: 7900,
|
||||||
|
plan_name_at_billing: 'Pro Plan',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
line_type: 'plan',
|
||||||
|
description: 'Pro Plan',
|
||||||
|
quantity: 1,
|
||||||
|
unit_amount: 7900,
|
||||||
|
total_amount: 7900,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
|
||||||
|
|
||||||
|
const result = await getInvoice(1);
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
|
||||||
|
expect(result).toEqual(mockInvoice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when invoice not found (404)', async () => {
|
||||||
|
const error = { response: { status: 404 } };
|
||||||
|
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const result = await getInvoice(999);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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';
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
username: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ export interface LoginResponse {
|
|||||||
business?: number;
|
business?: number;
|
||||||
business_name?: string;
|
business_name?: string;
|
||||||
business_subdomain?: string;
|
business_subdomain?: string;
|
||||||
|
can_send_messages?: boolean;
|
||||||
};
|
};
|
||||||
masquerade_stack?: MasqueradeStackEntry[];
|
masquerade_stack?: MasqueradeStackEntry[];
|
||||||
// MFA challenge response
|
// MFA challenge response
|
||||||
@@ -72,6 +73,9 @@ export interface User {
|
|||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
can_invite_staff?: boolean;
|
can_invite_staff?: boolean;
|
||||||
can_access_tickets?: boolean;
|
can_access_tickets?: boolean;
|
||||||
|
can_edit_schedule?: boolean;
|
||||||
|
can_send_messages?: boolean;
|
||||||
|
linked_resource_id?: number;
|
||||||
quota_overages?: QuotaOverage[];
|
quota_overages?: QuotaOverage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,3 +136,11 @@ export const stopMasquerade = async (
|
|||||||
);
|
);
|
||||||
return response.data;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
188
frontend/src/api/billing.ts
Normal file
188
frontend/src/api/billing.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Billing API
|
||||||
|
*
|
||||||
|
* API client functions for the billing/subscription system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entitlements - a map of feature codes to their values.
|
||||||
|
* Boolean features indicate permission (true/false).
|
||||||
|
* Integer features indicate limits.
|
||||||
|
*/
|
||||||
|
export interface Entitlements {
|
||||||
|
[key: string]: boolean | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan information (nested in PlanVersion)
|
||||||
|
*/
|
||||||
|
export interface Plan {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan version with pricing and features
|
||||||
|
*/
|
||||||
|
export interface PlanVersion {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
is_legacy: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
|
plan: Plan;
|
||||||
|
price_monthly_cents: number;
|
||||||
|
price_yearly_cents: number;
|
||||||
|
features?: PlanFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature attached to a plan version
|
||||||
|
*/
|
||||||
|
export interface PlanFeature {
|
||||||
|
feature_code: string;
|
||||||
|
feature_name: string;
|
||||||
|
feature_type: 'boolean' | 'integer';
|
||||||
|
bool_value?: boolean;
|
||||||
|
int_value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current subscription
|
||||||
|
*/
|
||||||
|
export interface Subscription {
|
||||||
|
id: number;
|
||||||
|
status: 'active' | 'canceled' | 'past_due' | 'trialing';
|
||||||
|
plan_version: PlanVersion;
|
||||||
|
current_period_start: string;
|
||||||
|
current_period_end: string;
|
||||||
|
canceled_at?: string;
|
||||||
|
stripe_subscription_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-on product
|
||||||
|
*/
|
||||||
|
export interface AddOnProduct {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
price_monthly_cents: number;
|
||||||
|
price_one_time_cents: number;
|
||||||
|
stripe_product_id?: string;
|
||||||
|
stripe_price_id?: string;
|
||||||
|
is_stackable: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice line item
|
||||||
|
*/
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id: number;
|
||||||
|
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice
|
||||||
|
*/
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
|
||||||
|
period_start: string;
|
||||||
|
period_end: string;
|
||||||
|
subtotal_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
plan_name_at_billing: string;
|
||||||
|
plan_code_at_billing?: string;
|
||||||
|
created_at: string;
|
||||||
|
paid_at?: string;
|
||||||
|
lines?: InvoiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get effective entitlements for the current business.
|
||||||
|
* Returns a map of feature codes to their values.
|
||||||
|
*/
|
||||||
|
export const getEntitlements = async (): Promise<Entitlements> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<Entitlements>('/me/entitlements/');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch entitlements:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current subscription for the business.
|
||||||
|
* Returns null if no subscription exists.
|
||||||
|
*/
|
||||||
|
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<Subscription>('/me/subscription/');
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.error('Failed to fetch subscription:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available plans (public, non-legacy plans).
|
||||||
|
*/
|
||||||
|
export const getPlans = async (): Promise<PlanVersion[]> => {
|
||||||
|
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available add-on products.
|
||||||
|
*/
|
||||||
|
export const getAddOns = async (): Promise<AddOnProduct[]> => {
|
||||||
|
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoices for the current business.
|
||||||
|
*/
|
||||||
|
export const getInvoices = async (): Promise<Invoice[]> => {
|
||||||
|
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single invoice by ID.
|
||||||
|
* Returns null if not found.
|
||||||
|
*/
|
||||||
|
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.error('Failed to fetch invoice:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
260
frontend/src/api/media.ts
Normal file
260
frontend/src/api/media.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Media Gallery API
|
||||||
|
*
|
||||||
|
* API client functions for managing media files and albums.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album for organizing media files
|
||||||
|
*/
|
||||||
|
export interface Album {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cover_image: number | null;
|
||||||
|
file_count: number;
|
||||||
|
cover_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media file (uploaded image)
|
||||||
|
*/
|
||||||
|
export interface MediaFile {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
alt_text: string;
|
||||||
|
file_size: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
mime_type: string;
|
||||||
|
album: number | null;
|
||||||
|
album_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage usage statistics
|
||||||
|
*/
|
||||||
|
export interface StorageUsage {
|
||||||
|
bytes_used: number;
|
||||||
|
bytes_total: number;
|
||||||
|
file_count: number;
|
||||||
|
percent_used: number;
|
||||||
|
used_display: string;
|
||||||
|
total_display: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album creation/update payload
|
||||||
|
*/
|
||||||
|
export interface AlbumPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
cover_image?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media file update payload (can't change the actual file)
|
||||||
|
*/
|
||||||
|
export interface MediaFileUpdatePayload {
|
||||||
|
alt_text?: string;
|
||||||
|
album?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Album API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all albums
|
||||||
|
*/
|
||||||
|
export async function listAlbums(): Promise<Album[]> {
|
||||||
|
const response = await apiClient.get('/albums/');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single album
|
||||||
|
*/
|
||||||
|
export async function getAlbum(id: number): Promise<Album> {
|
||||||
|
const response = await apiClient.get(`/albums/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new album
|
||||||
|
*/
|
||||||
|
export async function createAlbum(data: AlbumPayload): Promise<Album> {
|
||||||
|
const response = await apiClient.post('/albums/', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an album
|
||||||
|
*/
|
||||||
|
export async function updateAlbum(id: number, data: Partial<AlbumPayload>): Promise<Album> {
|
||||||
|
const response = await apiClient.patch(`/albums/${id}/`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an album (files are moved to uncategorized)
|
||||||
|
*/
|
||||||
|
export async function deleteAlbum(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/albums/${id}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Media File API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List media files
|
||||||
|
* @param albumId - Filter by album ID, 'null' for uncategorized, undefined for all
|
||||||
|
*/
|
||||||
|
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
||||||
|
const params = albumId !== undefined ? { album: albumId } : {};
|
||||||
|
const response = await apiClient.get('/media/', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single media file
|
||||||
|
*/
|
||||||
|
export async function getMediaFile(id: number): Promise<MediaFile> {
|
||||||
|
const response = await apiClient.get(`/media/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new media file
|
||||||
|
*/
|
||||||
|
export async function uploadMediaFile(
|
||||||
|
file: File,
|
||||||
|
albumId?: number | null,
|
||||||
|
altText?: string
|
||||||
|
): Promise<MediaFile> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (albumId !== undefined && albumId !== null) {
|
||||||
|
formData.append('album', albumId.toString());
|
||||||
|
}
|
||||||
|
if (altText) {
|
||||||
|
formData.append('alt_text', altText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post('/media/', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a media file (alt text, album assignment)
|
||||||
|
*/
|
||||||
|
export async function updateMediaFile(
|
||||||
|
id: number,
|
||||||
|
data: MediaFileUpdatePayload
|
||||||
|
): Promise<MediaFile> {
|
||||||
|
const response = await apiClient.patch(`/media/${id}/`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a media file
|
||||||
|
*/
|
||||||
|
export async function deleteMediaFile(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/media/${id}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move multiple files to an album
|
||||||
|
*/
|
||||||
|
export async function bulkMoveFiles(
|
||||||
|
fileIds: number[],
|
||||||
|
albumId: number | null
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
const response = await apiClient.post('/media/bulk_move/', {
|
||||||
|
file_ids: fileIds,
|
||||||
|
album_id: albumId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple files
|
||||||
|
*/
|
||||||
|
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
||||||
|
const response = await apiClient.post('/media/bulk_delete/', {
|
||||||
|
file_ids: fileIds,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Storage Usage API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage statistics
|
||||||
|
*/
|
||||||
|
export async function getStorageUsage(): Promise<StorageUsage> {
|
||||||
|
const response = await apiClient.get('/storage-usage/');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in human-readable format
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
} else if (bytes >= 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
} else if (bytes >= 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file type is allowed
|
||||||
|
*/
|
||||||
|
export function isAllowedFileType(file: File): boolean {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get allowed file types for input accept attribute
|
||||||
|
*/
|
||||||
|
export function getAllowedFileTypes(): string {
|
||||||
|
return 'image/jpeg,image/png,image/gif,image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum file size in bytes (10 MB)
|
||||||
|
*/
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file size is within limits
|
||||||
|
*/
|
||||||
|
export function isFileSizeAllowed(file: File): boolean {
|
||||||
|
return file.size <= MAX_FILE_SIZE;
|
||||||
|
}
|
||||||
@@ -443,7 +443,6 @@ export interface SubscriptionPlan {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
plan_type: 'base' | 'addon';
|
plan_type: 'base' | 'addon';
|
||||||
business_tier: string;
|
|
||||||
price_monthly: number | null;
|
price_monthly: number | null;
|
||||||
price_yearly: number | null;
|
price_yearly: number | null;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface PlatformBusiness {
|
|||||||
owner: PlatformBusinessOwner | null;
|
owner: PlatformBusinessOwner | null;
|
||||||
max_users: number;
|
max_users: number;
|
||||||
max_resources: number;
|
max_resources: number;
|
||||||
|
max_pages: number;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
// Platform permissions
|
// Platform permissions
|
||||||
@@ -33,6 +34,25 @@ export interface PlatformBusiness {
|
|||||||
can_use_custom_domain: boolean;
|
can_use_custom_domain: boolean;
|
||||||
can_white_label: boolean;
|
can_white_label: boolean;
|
||||||
can_api_access: boolean;
|
can_api_access: boolean;
|
||||||
|
// Feature permissions (optional - returned by API but may not always be present in tests)
|
||||||
|
can_add_video_conferencing?: boolean;
|
||||||
|
can_connect_to_api?: boolean;
|
||||||
|
can_book_repeated_events?: boolean;
|
||||||
|
can_require_2fa?: boolean;
|
||||||
|
can_download_logs?: boolean;
|
||||||
|
can_delete_data?: boolean;
|
||||||
|
can_use_sms_reminders?: boolean;
|
||||||
|
can_use_masked_phone_numbers?: boolean;
|
||||||
|
can_use_pos?: boolean;
|
||||||
|
can_use_mobile_app?: boolean;
|
||||||
|
can_export_data?: boolean;
|
||||||
|
can_use_plugins?: boolean;
|
||||||
|
can_use_tasks?: boolean;
|
||||||
|
can_create_plugins?: boolean;
|
||||||
|
can_use_webhooks?: boolean;
|
||||||
|
can_use_calendar_sync?: boolean;
|
||||||
|
can_use_contracts?: boolean;
|
||||||
|
can_customize_booking_page?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformBusinessUpdate {
|
export interface PlatformBusinessUpdate {
|
||||||
@@ -41,11 +61,39 @@ export interface PlatformBusinessUpdate {
|
|||||||
subscription_tier?: string;
|
subscription_tier?: string;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_resources?: number;
|
max_resources?: number;
|
||||||
|
max_pages?: number;
|
||||||
|
// Platform permissions
|
||||||
can_manage_oauth_credentials?: boolean;
|
can_manage_oauth_credentials?: boolean;
|
||||||
can_accept_payments?: boolean;
|
can_accept_payments?: boolean;
|
||||||
can_use_custom_domain?: boolean;
|
can_use_custom_domain?: boolean;
|
||||||
can_white_label?: boolean;
|
can_white_label?: boolean;
|
||||||
can_api_access?: boolean;
|
can_api_access?: boolean;
|
||||||
|
// Feature permissions
|
||||||
|
can_add_video_conferencing?: boolean;
|
||||||
|
can_connect_to_api?: boolean;
|
||||||
|
can_book_repeated_events?: boolean;
|
||||||
|
can_require_2fa?: boolean;
|
||||||
|
can_download_logs?: boolean;
|
||||||
|
can_delete_data?: boolean;
|
||||||
|
can_use_sms_reminders?: boolean;
|
||||||
|
can_use_masked_phone_numbers?: boolean;
|
||||||
|
can_use_pos?: boolean;
|
||||||
|
can_use_mobile_app?: boolean;
|
||||||
|
can_export_data?: boolean;
|
||||||
|
can_use_plugins?: boolean;
|
||||||
|
can_use_tasks?: boolean;
|
||||||
|
can_create_plugins?: boolean;
|
||||||
|
can_use_webhooks?: boolean;
|
||||||
|
can_use_calendar_sync?: boolean;
|
||||||
|
can_use_contracts?: boolean;
|
||||||
|
can_customize_booking_page?: boolean;
|
||||||
|
can_process_refunds?: boolean;
|
||||||
|
can_create_packages?: boolean;
|
||||||
|
can_use_email_templates?: boolean;
|
||||||
|
advanced_reporting?: boolean;
|
||||||
|
priority_support?: boolean;
|
||||||
|
dedicated_support?: boolean;
|
||||||
|
sso_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformBusinessCreate {
|
export interface PlatformBusinessCreate {
|
||||||
@@ -55,6 +103,7 @@ export interface PlatformBusinessCreate {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
max_resources?: number;
|
max_resources?: number;
|
||||||
|
max_pages?: number;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
can_manage_oauth_credentials?: boolean;
|
can_manage_oauth_credentials?: boolean;
|
||||||
@@ -103,6 +152,27 @@ export const updateBusiness = async (
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a business's subscription plan (platform admin only)
|
||||||
|
*/
|
||||||
|
export interface ChangePlanResponse {
|
||||||
|
detail: string;
|
||||||
|
plan_code: string;
|
||||||
|
plan_name: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const changeBusinessPlan = async (
|
||||||
|
businessId: number,
|
||||||
|
planCode: string
|
||||||
|
): Promise<ChangePlanResponse> => {
|
||||||
|
const response = await apiClient.post<ChangePlanResponse>(
|
||||||
|
`/platform/businesses/${businessId}/change_plan/`,
|
||||||
|
{ plan_code: planCode }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new business (platform admin only)
|
* Create a new business (platform admin only)
|
||||||
*/
|
*/
|
||||||
@@ -116,6 +186,14 @@ export const createBusiness = async (
|
|||||||
return response.data;
|
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)
|
* Get all users (platform admin only)
|
||||||
*/
|
*/
|
||||||
@@ -272,3 +350,46 @@ export const acceptInvitation = async (
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tenant Custom Tier
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { TenantCustomTier } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a business's custom tier (if it exists)
|
||||||
|
*/
|
||||||
|
export const getCustomTier = async (businessId: number): Promise<TenantCustomTier | null> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<TenantCustomTier>(`/platform/businesses/${businessId}/custom_tier/`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or create a custom tier for a business
|
||||||
|
*/
|
||||||
|
export const updateCustomTier = async (
|
||||||
|
businessId: number,
|
||||||
|
features: Record<string, boolean | number>,
|
||||||
|
notes?: string
|
||||||
|
): Promise<TenantCustomTier> => {
|
||||||
|
const response = await apiClient.put<TenantCustomTier>(
|
||||||
|
`/platform/businesses/${businessId}/custom_tier/`,
|
||||||
|
{ features, notes }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a business's custom tier
|
||||||
|
*/
|
||||||
|
export const deleteCustomTier = async (businessId: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/platform/businesses/${businessId}/custom_tier/`);
|
||||||
|
};
|
||||||
|
|||||||
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal file
376
frontend/src/billing/components/AddOnEditorModal.tsx
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* AddOnEditorModal Component
|
||||||
|
*
|
||||||
|
* Modal for creating or editing add-on products with feature selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Modal, FormInput, Alert } from '../../components/ui';
|
||||||
|
import { FeaturePicker } from './FeaturePicker';
|
||||||
|
import {
|
||||||
|
useFeatures,
|
||||||
|
useCreateAddOnProduct,
|
||||||
|
useUpdateAddOnProduct,
|
||||||
|
type AddOnProduct,
|
||||||
|
type AddOnFeatureWrite,
|
||||||
|
} from '../../hooks/useBillingAdmin';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AddOnEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
addon?: AddOnProduct | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price_monthly_cents: number;
|
||||||
|
price_one_time_cents: number;
|
||||||
|
stripe_product_id: string;
|
||||||
|
stripe_price_id: string;
|
||||||
|
is_stackable: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
selectedFeatures: AddOnFeatureWrite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const AddOnEditorModal: React.FC<AddOnEditorModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
addon,
|
||||||
|
}) => {
|
||||||
|
const isEditMode = !!addon;
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price_monthly_cents: 0,
|
||||||
|
price_one_time_cents: 0,
|
||||||
|
stripe_product_id: '',
|
||||||
|
stripe_price_id: '',
|
||||||
|
is_stackable: false,
|
||||||
|
is_active: true,
|
||||||
|
selectedFeatures: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||||
|
|
||||||
|
// Fetch features
|
||||||
|
const { data: features, isLoading: featuresLoading } = useFeatures();
|
||||||
|
|
||||||
|
const createMutation = useCreateAddOnProduct();
|
||||||
|
const updateMutation = useUpdateAddOnProduct();
|
||||||
|
|
||||||
|
// Initialize form when addon changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (addon) {
|
||||||
|
setFormData({
|
||||||
|
code: addon.code,
|
||||||
|
name: addon.name,
|
||||||
|
description: addon.description || '',
|
||||||
|
price_monthly_cents: addon.price_monthly_cents,
|
||||||
|
price_one_time_cents: addon.price_one_time_cents,
|
||||||
|
stripe_product_id: addon.stripe_product_id || '',
|
||||||
|
stripe_price_id: addon.stripe_price_id || '',
|
||||||
|
is_stackable: addon.is_stackable,
|
||||||
|
is_active: addon.is_active,
|
||||||
|
selectedFeatures:
|
||||||
|
addon.features?.map((af) => ({
|
||||||
|
feature_code: af.feature.code,
|
||||||
|
bool_value: af.bool_value,
|
||||||
|
int_value: af.int_value,
|
||||||
|
})) || [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price_monthly_cents: 0,
|
||||||
|
price_one_time_cents: 0,
|
||||||
|
stripe_product_id: '',
|
||||||
|
stripe_price_id: '',
|
||||||
|
is_stackable: false,
|
||||||
|
is_active: true,
|
||||||
|
selectedFeatures: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
}, [addon, isOpen]);
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: Partial<Record<keyof FormData, string>> = {};
|
||||||
|
|
||||||
|
if (!formData.code.trim()) {
|
||||||
|
newErrors.code = 'Code is required';
|
||||||
|
} else if (!/^[a-z0-9_]+$/.test(formData.code)) {
|
||||||
|
newErrors.code = 'Code must be lowercase letters, numbers, and underscores only';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
newErrors.name = 'Name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.price_monthly_cents < 0) {
|
||||||
|
newErrors.price_monthly_cents = 'Price cannot be negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.price_one_time_cents < 0) {
|
||||||
|
newErrors.price_one_time_cents = 'Price cannot be negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
code: formData.code,
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
price_monthly_cents: formData.price_monthly_cents,
|
||||||
|
price_one_time_cents: formData.price_one_time_cents,
|
||||||
|
stripe_product_id: formData.stripe_product_id,
|
||||||
|
stripe_price_id: formData.stripe_price_id,
|
||||||
|
is_stackable: formData.is_stackable,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
features: formData.selectedFeatures,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditMode && addon) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: addon.id,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save add-on:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: keyof FormData, value: string | number | boolean) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditMode ? `Edit ${addon?.name}` : 'Create Add-On'}
|
||||||
|
size="4xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Basic Information</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormInput
|
||||||
|
label="Code"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => handleChange('code', e.target.value)}
|
||||||
|
error={errors.code}
|
||||||
|
placeholder="sms_credits_pack"
|
||||||
|
disabled={isEditMode}
|
||||||
|
hint={isEditMode ? 'Code cannot be changed' : 'Unique identifier (lowercase, underscores)'}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
error={errors.name}
|
||||||
|
placeholder="SMS Credits Pack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
placeholder="Description of the add-on..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Active (available for purchase)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_stackable"
|
||||||
|
checked={formData.is_stackable}
|
||||||
|
onChange={(e) => handleChange('is_stackable', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_stackable" className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Stackable (can purchase multiple, values compound)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Pricing</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Monthly Price
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={(formData.price_monthly_cents / 100).toFixed(2)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('price_monthly_cents', Math.round(parseFloat(e.target.value || '0') * 100))
|
||||||
|
}
|
||||||
|
className="w-full pl-7 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.price_monthly_cents && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.price_monthly_cents}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
One-Time Price
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={(formData.price_one_time_cents / 100).toFixed(2)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('price_one_time_cents', Math.round(parseFloat(e.target.value || '0') * 100))
|
||||||
|
}
|
||||||
|
className="w-full pl-7 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.price_one_time_cents && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.price_one_time_cents}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
For one-time purchases (credits, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Features Granted by This Add-On
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Select the features that subscribers will receive when they purchase this add-on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{featuresLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FeaturePicker
|
||||||
|
features={features || []}
|
||||||
|
selectedFeatures={formData.selectedFeatures}
|
||||||
|
onChange={(selected) =>
|
||||||
|
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe Integration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Stripe Integration</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormInput
|
||||||
|
label="Stripe Product ID"
|
||||||
|
value={formData.stripe_product_id}
|
||||||
|
onChange={(e) => handleChange('stripe_product_id', e.target.value)}
|
||||||
|
placeholder="prod_..."
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="Stripe Price ID"
|
||||||
|
value={formData.stripe_price_id}
|
||||||
|
onChange={(e) => handleChange('stripe_price_id', e.target.value)}
|
||||||
|
placeholder="price_..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formData.stripe_product_id && (
|
||||||
|
<Alert
|
||||||
|
variant="info"
|
||||||
|
message="Configure Stripe IDs to enable purchasing. Create the product in Stripe Dashboard first."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{isEditMode ? 'Save Changes' : 'Create Add-On'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal file
337
frontend/src/billing/components/CatalogListPanel.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* CatalogListPanel Component
|
||||||
|
*
|
||||||
|
* Left sidebar panel displaying a searchable, filterable list of plans and add-ons.
|
||||||
|
* Supports filtering by type, status, visibility, and legacy status.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Search, Plus, Package, Puzzle, Eye, EyeOff, Archive } from 'lucide-react';
|
||||||
|
import { Badge } from '../../components/ui';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface CatalogItem {
|
||||||
|
id: number;
|
||||||
|
type: 'plan' | 'addon';
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
|
isLegacy: boolean;
|
||||||
|
priceMonthly?: number;
|
||||||
|
priceYearly?: number;
|
||||||
|
subscriberCount?: number;
|
||||||
|
stripeProductId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogListPanelProps {
|
||||||
|
items: CatalogItem[];
|
||||||
|
selectedId: number | null;
|
||||||
|
onSelect: (item: CatalogItem) => void;
|
||||||
|
onCreatePlan: () => void;
|
||||||
|
onCreateAddon: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypeFilter = 'all' | 'plan' | 'addon';
|
||||||
|
type StatusFilter = 'all' | 'active' | 'inactive';
|
||||||
|
type VisibilityFilter = 'all' | 'public' | 'hidden';
|
||||||
|
type LegacyFilter = 'all' | 'current' | 'legacy';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const CatalogListPanel: React.FC<CatalogListPanelProps> = ({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onCreatePlan,
|
||||||
|
onCreateAddon,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all');
|
||||||
|
const [legacyFilter, setLegacyFilter] = useState<LegacyFilter>('all');
|
||||||
|
|
||||||
|
// Filter items
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return items.filter((item) => {
|
||||||
|
// Type filter
|
||||||
|
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter === 'active' && !item.isActive) return false;
|
||||||
|
if (statusFilter === 'inactive' && item.isActive) return false;
|
||||||
|
|
||||||
|
// Visibility filter
|
||||||
|
if (visibilityFilter === 'public' && !item.isPublic) return false;
|
||||||
|
if (visibilityFilter === 'hidden' && item.isPublic) return false;
|
||||||
|
|
||||||
|
// Legacy filter
|
||||||
|
if (legacyFilter === 'current' && item.isLegacy) return false;
|
||||||
|
if (legacyFilter === 'legacy' && !item.isLegacy) return false;
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(term) ||
|
||||||
|
item.code.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [items, typeFilter, statusFilter, visibilityFilter, legacyFilter, searchTerm]);
|
||||||
|
|
||||||
|
const formatPrice = (cents?: number): string => {
|
||||||
|
if (cents === undefined || cents === 0) return 'Free';
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Header with Create buttons */}
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCreatePlan}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create Plan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCreateAddon}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Create Add-on
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search by name or code..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="type-filter" className="sr-only">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="type-filter"
|
||||||
|
aria-label="Type"
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value as TypeFilter)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="plan">Base Plans</option>
|
||||||
|
<option value="addon">Add-ons</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="status-filter" className="sr-only">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="status-filter"
|
||||||
|
aria-label="Status"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="visibility-filter" className="sr-only">
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="visibility-filter"
|
||||||
|
aria-label="Visibility"
|
||||||
|
value={visibilityFilter}
|
||||||
|
onChange={(e) => setVisibilityFilter(e.target.value as VisibilityFilter)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Visibility</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="hidden">Hidden</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="legacy-filter" className="sr-only">
|
||||||
|
Legacy
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="legacy-filter"
|
||||||
|
aria-label="Legacy"
|
||||||
|
value={legacyFilter}
|
||||||
|
onChange={(e) => setLegacyFilter(e.target.value as LegacyFilter)}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Versions</option>
|
||||||
|
<option value="current">Current</option>
|
||||||
|
<option value="legacy">Legacy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No items found</p>
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="text-xs mt-1">Try adjusting your search or filters</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<CatalogListItem
|
||||||
|
key={`${item.type}-${item.id}`}
|
||||||
|
item={item}
|
||||||
|
isSelected={selectedId === item.id}
|
||||||
|
onSelect={() => onSelect(item)}
|
||||||
|
formatPrice={formatPrice}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// List Item Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface CatalogListItemProps {
|
||||||
|
item: CatalogItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
formatPrice: (cents?: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CatalogListItem: React.FC<CatalogListItemProps> = ({
|
||||||
|
item,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
formatPrice,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`w-full p-4 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-600'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-lg ${
|
||||||
|
item.type === 'plan'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.type === 'plan' ? (
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Puzzle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||||
|
{item.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||||
|
{/* Type badge */}
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 text-xs font-medium rounded ${
|
||||||
|
item.type === 'plan'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||||
|
: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.type === 'plan' ? 'Base' : 'Add-on'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
{!item.isActive && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visibility badge */}
|
||||||
|
{!item.isPublic && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded flex items-center gap-1">
|
||||||
|
<EyeOff className="w-3 h-3" />
|
||||||
|
Hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy badge */}
|
||||||
|
{item.isLegacy && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded flex items-center gap-1">
|
||||||
|
<Archive className="w-3 h-3" />
|
||||||
|
Legacy
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price and subscriber count */}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{formatPrice(item.priceMonthly)}/mo
|
||||||
|
</span>
|
||||||
|
{item.subscriberCount !== undefined && (
|
||||||
|
<span>{item.subscriberCount} subscribers</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
252
frontend/src/billing/components/FeaturePicker.tsx
Normal file
252
frontend/src/billing/components/FeaturePicker.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* FeaturePicker Component
|
||||||
|
*
|
||||||
|
* A searchable picker for selecting features to include in a plan or version.
|
||||||
|
* Features are grouped by type (boolean capabilities vs integer limits).
|
||||||
|
* Features are loaded dynamically from the billing API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Check, Sliders, Search, X } from 'lucide-react';
|
||||||
|
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
||||||
|
|
||||||
|
export interface FeaturePickerProps {
|
||||||
|
/** Available features from the API */
|
||||||
|
features: Feature[];
|
||||||
|
/** Currently selected features with their values */
|
||||||
|
selectedFeatures: PlanFeatureWrite[];
|
||||||
|
/** Callback when selection changes */
|
||||||
|
onChange: (features: PlanFeatureWrite[]) => void;
|
||||||
|
/** Optional: Show compact view */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||||
|
features,
|
||||||
|
selectedFeatures,
|
||||||
|
onChange,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Group features by type
|
||||||
|
const { booleanFeatures, integerFeatures } = useMemo(() => {
|
||||||
|
const boolean = features.filter((f) => f.feature_type === 'boolean');
|
||||||
|
const integer = features.filter((f) => f.feature_type === 'integer');
|
||||||
|
return { booleanFeatures: boolean, integerFeatures: integer };
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
const filteredBooleanFeatures = useMemo(() => {
|
||||||
|
if (!searchTerm) return booleanFeatures;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return booleanFeatures.filter(
|
||||||
|
(f) =>
|
||||||
|
f.name.toLowerCase().includes(term) ||
|
||||||
|
f.code.toLowerCase().includes(term) ||
|
||||||
|
f.description?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [booleanFeatures, searchTerm]);
|
||||||
|
|
||||||
|
const filteredIntegerFeatures = useMemo(() => {
|
||||||
|
if (!searchTerm) return integerFeatures;
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return integerFeatures.filter(
|
||||||
|
(f) =>
|
||||||
|
f.name.toLowerCase().includes(term) ||
|
||||||
|
f.code.toLowerCase().includes(term) ||
|
||||||
|
f.description?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [integerFeatures, searchTerm]);
|
||||||
|
|
||||||
|
const hasNoResults =
|
||||||
|
searchTerm && filteredBooleanFeatures.length === 0 && filteredIntegerFeatures.length === 0;
|
||||||
|
|
||||||
|
// Check if a feature is selected
|
||||||
|
const isSelected = (code: string): boolean => {
|
||||||
|
return selectedFeatures.some((f) => f.feature_code === code);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get selected feature data
|
||||||
|
const getSelectedFeature = (code: string): PlanFeatureWrite | undefined => {
|
||||||
|
return selectedFeatures.find((f) => f.feature_code === code);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle boolean feature selection
|
||||||
|
const toggleBooleanFeature = (code: string) => {
|
||||||
|
if (isSelected(code)) {
|
||||||
|
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
||||||
|
} else {
|
||||||
|
onChange([
|
||||||
|
...selectedFeatures,
|
||||||
|
{ feature_code: code, bool_value: true, int_value: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle integer feature selection
|
||||||
|
const toggleIntegerFeature = (code: string) => {
|
||||||
|
if (isSelected(code)) {
|
||||||
|
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
||||||
|
} else {
|
||||||
|
onChange([
|
||||||
|
...selectedFeatures,
|
||||||
|
{ feature_code: code, bool_value: null, int_value: 0 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update integer feature value
|
||||||
|
const updateIntegerValue = (code: string, value: number) => {
|
||||||
|
onChange(
|
||||||
|
selectedFeatures.map((f) =>
|
||||||
|
f.feature_code === code ? { ...f, int_value: value } : f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
setSearchTerm('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search features..."
|
||||||
|
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearSearch}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No Results Message */}
|
||||||
|
{hasNoResults && (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||||
|
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No features found matching "{searchTerm}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Boolean Features (Capabilities) */}
|
||||||
|
{filteredBooleanFeatures.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4" /> Capabilities
|
||||||
|
</h4>
|
||||||
|
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
||||||
|
{filteredBooleanFeatures.map((feature) => {
|
||||||
|
const selected = isSelected(feature.code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={feature.id}
|
||||||
|
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleBooleanFeature(feature.code)}
|
||||||
|
aria-label={feature.name}
|
||||||
|
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{feature.name}
|
||||||
|
</span>
|
||||||
|
{feature.description && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||||
|
{feature.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integer Features (Limits & Quotas) */}
|
||||||
|
{filteredIntegerFeatures.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
|
<Sliders className="w-4 h-4" /> Limits & Quotas
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Set to 0 for unlimited. Uncheck to exclude from plan.
|
||||||
|
</p>
|
||||||
|
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||||
|
{filteredIntegerFeatures.map((feature) => {
|
||||||
|
const selectedFeature = getSelectedFeature(feature.code);
|
||||||
|
const selected = !!selectedFeature;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.id}
|
||||||
|
className={`flex items-center gap-3 p-3 border rounded-lg ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => toggleIntegerFeature(feature.code)}
|
||||||
|
aria-label={feature.name}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
||||||
|
{feature.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{selected && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={selectedFeature?.int_value || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateIntegerValue(feature.code, parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
aria-label={`${feature.name} limit value`}
|
||||||
|
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!searchTerm && features.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||||
|
<Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No features defined yet.</p>
|
||||||
|
<p className="text-xs mt-1">Add features in the Features Library tab first.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
766
frontend/src/billing/components/PlanDetailPanel.tsx
Normal file
766
frontend/src/billing/components/PlanDetailPanel.tsx
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
/**
|
||||||
|
* PlanDetailPanel Component
|
||||||
|
*
|
||||||
|
* Detail view for a selected plan or add-on, shown in the main panel
|
||||||
|
* of the master-detail layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
ExternalLink,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Archive,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge, Alert, Modal, ModalFooter } from '../../components/ui';
|
||||||
|
import {
|
||||||
|
usePlanVersionSubscribers,
|
||||||
|
useDeletePlan,
|
||||||
|
useDeletePlanVersion,
|
||||||
|
useMarkVersionLegacy,
|
||||||
|
useForceUpdatePlanVersion,
|
||||||
|
formatCentsToDollars,
|
||||||
|
type PlanWithVersions,
|
||||||
|
type PlanVersion,
|
||||||
|
type AddOnProduct,
|
||||||
|
} from '../../hooks/useBillingAdmin';
|
||||||
|
import { useCurrentUser } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface PlanDetailPanelProps {
|
||||||
|
plan: PlanWithVersions | null;
|
||||||
|
addon: AddOnProduct | null;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onCreateVersion: () => void;
|
||||||
|
onEditVersion: (version: PlanVersion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const PlanDetailPanel: React.FC<PlanDetailPanelProps> = ({
|
||||||
|
plan,
|
||||||
|
addon,
|
||||||
|
onEdit,
|
||||||
|
onDuplicate,
|
||||||
|
onCreateVersion,
|
||||||
|
onEditVersion,
|
||||||
|
}) => {
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
|
new Set(['overview', 'pricing', 'features'])
|
||||||
|
);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||||
|
const [showForcePushModal, setShowForcePushModal] = useState(false);
|
||||||
|
const [forcePushConfirmText, setForcePushConfirmText] = useState('');
|
||||||
|
const [forcePushError, setForcePushError] = useState<string | null>(null);
|
||||||
|
const [forcePushSuccess, setForcePushSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: currentUser } = useCurrentUser();
|
||||||
|
const isSuperuser = currentUser?.is_superuser ?? false;
|
||||||
|
|
||||||
|
const deletePlanMutation = useDeletePlan();
|
||||||
|
const deleteVersionMutation = useDeletePlanVersion();
|
||||||
|
const markLegacyMutation = useMarkVersionLegacy();
|
||||||
|
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||||||
|
|
||||||
|
if (!plan && !addon) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Package className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Select a plan or add-on from the catalog</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(section)) {
|
||||||
|
next.delete(section);
|
||||||
|
} else {
|
||||||
|
next.add(section);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeVersion = plan?.active_version;
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
const expectedText = `DELETE ${plan.code}`;
|
||||||
|
if (deleteConfirmText !== expectedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePlanMutation.mutateAsync(plan.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeleteConfirmText('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete plan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForcePush = async () => {
|
||||||
|
if (!plan || !activeVersion) return;
|
||||||
|
|
||||||
|
const expectedText = `FORCE PUSH ${plan.code}`;
|
||||||
|
if (forcePushConfirmText !== expectedText) {
|
||||||
|
setForcePushError('Please type the confirmation text exactly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForcePushError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await forceUpdateMutation.mutateAsync({
|
||||||
|
id: activeVersion.id,
|
||||||
|
confirm: true,
|
||||||
|
// Pass current version data to ensure it's updated in place
|
||||||
|
name: activeVersion.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('version' in result) {
|
||||||
|
setForcePushSuccess(
|
||||||
|
`Successfully pushed changes to ${result.affected_count} subscriber(s).`
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowForcePushModal(false);
|
||||||
|
setForcePushConfirmText('');
|
||||||
|
setForcePushSuccess(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.detail || error.message || 'Failed to force push';
|
||||||
|
setForcePushError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Plan Detail
|
||||||
|
if (plan) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-6 z-10">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{plan.name}
|
||||||
|
</h2>
|
||||||
|
<span className="px-2 py-1 text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||||
|
{plan.code}
|
||||||
|
</span>
|
||||||
|
{!plan.is_active && <Badge variant="warning">Inactive</Badge>}
|
||||||
|
</div>
|
||||||
|
{plan.description && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{plan.description}</p>
|
||||||
|
)}
|
||||||
|
{activeVersion && (
|
||||||
|
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
{activeVersion.price_monthly_cents === 0
|
||||||
|
? 'Free'
|
||||||
|
: `$${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo`}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{plan.total_subscribers} subscriber{plan.total_subscribers !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span>Version {activeVersion.version}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDuplicate}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCreateVersion}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Version
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Overview Section */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Overview"
|
||||||
|
isExpanded={expandedSections.has('overview')}
|
||||||
|
onToggle={() => toggleSection('overview')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Plan Code</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{plan.code}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Display Order</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{plan.display_order}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Status</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{plan.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Total Subscribers</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{plan.total_subscribers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Pricing Section */}
|
||||||
|
{activeVersion && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Pricing"
|
||||||
|
isExpanded={expandedSections.has('pricing')}
|
||||||
|
onToggle={() => toggleSection('pricing')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly</label>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
${formatCentsToDollars(activeVersion.price_monthly_cents)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Yearly</label>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
${formatCentsToDollars(activeVersion.price_yearly_cents)}
|
||||||
|
</p>
|
||||||
|
{activeVersion.price_yearly_cents > 0 && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
${(activeVersion.price_yearly_cents / 12 / 100).toFixed(2)}/mo
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Trial</label>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{activeVersion.trial_days} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transaction Fees Section */}
|
||||||
|
{activeVersion && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Transaction Fees"
|
||||||
|
isExpanded={expandedSections.has('fees')}
|
||||||
|
onToggle={() => toggleSection('fees')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Percentage</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{activeVersion.transaction_fee_percent}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Fixed Fee</label>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
${(activeVersion.transaction_fee_fixed_cents / 100).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
{activeVersion && activeVersion.features.length > 0 && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title={`Features (${activeVersion.features.length})`}
|
||||||
|
isExpanded={expandedSections.has('features')}
|
||||||
|
onToggle={() => toggleSection('features')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{activeVersion.features.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{f.feature.name}
|
||||||
|
</span>
|
||||||
|
{f.int_value !== null && (
|
||||||
|
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{f.int_value === 0 ? 'Unlimited' : f.int_value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stripe Section */}
|
||||||
|
{activeVersion &&
|
||||||
|
(activeVersion.stripe_product_id ||
|
||||||
|
activeVersion.stripe_price_id_monthly ||
|
||||||
|
activeVersion.stripe_price_id_yearly) && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Stripe Integration"
|
||||||
|
isExpanded={expandedSections.has('stripe')}
|
||||||
|
onToggle={() => toggleSection('stripe')}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeVersion.stripe_product_id && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">Product:</span>
|
||||||
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||||
|
{activeVersion.stripe_product_id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeVersion.stripe_price_id_monthly && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Monthly Price:
|
||||||
|
</span>
|
||||||
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||||
|
{activeVersion.stripe_price_id_monthly}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeVersion.stripe_price_id_yearly && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Yearly Price:
|
||||||
|
</span>
|
||||||
|
<code className="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||||
|
{activeVersion.stripe_price_id_yearly}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!activeVersion.stripe_product_id && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
message="No Stripe Product ID configured. This plan cannot be purchased until Stripe is set up."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Versions Section */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title={`Versions (${plan.versions.length})`}
|
||||||
|
isExpanded={expandedSections.has('versions')}
|
||||||
|
onToggle={() => toggleSection('versions')}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{plan.versions.map((version) => (
|
||||||
|
<VersionRow
|
||||||
|
key={version.id}
|
||||||
|
version={version}
|
||||||
|
isActive={!version.is_legacy}
|
||||||
|
onEdit={() => onEditVersion(version)}
|
||||||
|
onMarkLegacy={() => markLegacyMutation.mutate(version.id)}
|
||||||
|
onDelete={() => deleteVersionMutation.mutate(version.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Danger Zone"
|
||||||
|
isExpanded={expandedSections.has('danger')}
|
||||||
|
onToggle={() => toggleSection('danger')}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Force Push to Subscribers - Superuser Only */}
|
||||||
|
{isSuperuser && activeVersion && plan.total_subscribers > 0 && (
|
||||||
|
<div className="p-4 border border-orange-200 dark:border-orange-800 rounded-lg bg-orange-50 dark:bg-orange-900/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-orange-800 dark:text-orange-200 mb-1">
|
||||||
|
Force Push Changes to All Subscribers
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-orange-700 dark:text-orange-300 mb-3">
|
||||||
|
This will modify the current plan version in place, immediately affecting
|
||||||
|
all {plan.total_subscribers} active subscriber(s). This bypasses grandfathering
|
||||||
|
and cannot be undone. Changes to pricing, features, and limits will take
|
||||||
|
effect immediately.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForcePushModal(true);
|
||||||
|
setForcePushError(null);
|
||||||
|
setForcePushSuccess(null);
|
||||||
|
setForcePushConfirmText('');
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Force Push to Subscribers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Plan */}
|
||||||
|
<div className="p-4 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Deleting a plan is permanent and cannot be undone. Plans with active subscribers
|
||||||
|
cannot be deleted.
|
||||||
|
</p>
|
||||||
|
{plan.total_subscribers > 0 ? (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
message={`This plan has ${plan.total_subscribers} active subscriber(s) and cannot be deleted.`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<Modal
|
||||||
|
isOpen
|
||||||
|
onClose={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeleteConfirmText('');
|
||||||
|
}}
|
||||||
|
title="Delete Plan"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert
|
||||||
|
variant="error"
|
||||||
|
message="This action cannot be undone. This will permanently delete the plan and all its versions."
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
To confirm, type <strong>DELETE {plan.code}</strong> below:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deleteConfirmText}
|
||||||
|
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder={`DELETE ${plan.code}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ModalFooter
|
||||||
|
onCancel={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setDeleteConfirmText('');
|
||||||
|
}}
|
||||||
|
submitText="Delete Plan"
|
||||||
|
submitVariant="danger"
|
||||||
|
isDisabled={deleteConfirmText !== `DELETE ${plan.code}`}
|
||||||
|
isLoading={deletePlanMutation.isPending}
|
||||||
|
onSubmit={handleDelete}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Force Push Confirmation Modal */}
|
||||||
|
{showForcePushModal && activeVersion && (
|
||||||
|
<Modal
|
||||||
|
isOpen
|
||||||
|
onClose={() => {
|
||||||
|
setShowForcePushModal(false);
|
||||||
|
setForcePushConfirmText('');
|
||||||
|
setForcePushError(null);
|
||||||
|
setForcePushSuccess(null);
|
||||||
|
}}
|
||||||
|
title="Force Push to All Subscribers"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{forcePushSuccess ? (
|
||||||
|
<Alert variant="success" message={forcePushSuccess} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
variant="error"
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<strong>DANGER: This action affects paying customers!</strong>
|
||||||
|
<ul className="mt-2 ml-4 list-disc text-sm">
|
||||||
|
<li>All {plan.total_subscribers} subscriber(s) will be affected immediately</li>
|
||||||
|
<li>Changes to pricing will apply to future billing cycles</li>
|
||||||
|
<li>Feature and limit changes take effect immediately</li>
|
||||||
|
<li>This bypasses grandfathering protection</li>
|
||||||
|
<li>This action cannot be undone</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<strong>Current version:</strong> v{activeVersion.version} - {activeVersion.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Price:</strong> ${formatCentsToDollars(activeVersion.price_monthly_cents)}/mo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forcePushError && (
|
||||||
|
<Alert variant="error" message={forcePushError} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
To confirm this dangerous action, type <strong>FORCE PUSH {plan.code}</strong> below:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={forcePushConfirmText}
|
||||||
|
onChange={(e) => setForcePushConfirmText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder={`FORCE PUSH ${plan.code}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!forcePushSuccess && (
|
||||||
|
<ModalFooter
|
||||||
|
onCancel={() => {
|
||||||
|
setShowForcePushModal(false);
|
||||||
|
setForcePushConfirmText('');
|
||||||
|
setForcePushError(null);
|
||||||
|
}}
|
||||||
|
submitText="Force Push Changes"
|
||||||
|
submitVariant="danger"
|
||||||
|
isDisabled={forcePushConfirmText !== `FORCE PUSH ${plan.code}`}
|
||||||
|
isLoading={forceUpdateMutation.isPending}
|
||||||
|
onSubmit={handleForcePush}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Add-on Detail
|
||||||
|
if (addon) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{addon.name}
|
||||||
|
</h2>
|
||||||
|
<span className="px-2 py-1 text-sm font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded">
|
||||||
|
{addon.code}
|
||||||
|
</span>
|
||||||
|
{!addon.is_active && <Badge variant="warning">Inactive</Badge>}
|
||||||
|
</div>
|
||||||
|
{addon.description && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">{addon.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">Monthly Price</label>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
${formatCentsToDollars(addon.price_monthly_cents)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<label className="text-sm text-gray-500 dark:text-gray-400">One-time Price</label>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
${formatCentsToDollars(addon.price_one_time_cents)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Collapsible Section
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface CollapsibleSectionProps {
|
||||||
|
title: string;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||||
|
title,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border rounded-lg ${
|
||||||
|
variant === 'danger'
|
||||||
|
? 'border-red-200 dark:border-red-800'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`w-full flex items-center justify-between p-4 text-left ${
|
||||||
|
variant === 'danger'
|
||||||
|
? 'text-red-700 dark:text-red-400'
|
||||||
|
: 'text-gray-900 dark:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Version Row
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface VersionRowProps {
|
||||||
|
version: PlanVersion;
|
||||||
|
isActive: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onMarkLegacy: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VersionRow: React.FC<VersionRowProps> = ({
|
||||||
|
version,
|
||||||
|
isActive,
|
||||||
|
onEdit,
|
||||||
|
onMarkLegacy,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg ${
|
||||||
|
version.is_legacy
|
||||||
|
? 'bg-gray-50 dark:bg-gray-700/50'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">v{version.version}</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{version.name}</span>
|
||||||
|
{version.is_legacy && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded">
|
||||||
|
Legacy
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!version.is_public && !version.is_legacy && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||||
|
Hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{version.subscriber_count} subscriber{version.subscriber_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title="Edit version"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!version.is_legacy && version.subscriber_count === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onMarkLegacy}
|
||||||
|
className="p-1 text-gray-400 hover:text-amber-600 dark:hover:text-amber-400"
|
||||||
|
title="Mark as legacy"
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{version.subscriber_count === 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
title="Delete version"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
992
frontend/src/billing/components/PlanEditorWizard.tsx
Normal file
992
frontend/src/billing/components/PlanEditorWizard.tsx
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
/**
|
||||||
|
* PlanEditorWizard Component
|
||||||
|
*
|
||||||
|
* A multi-step wizard for creating or editing subscription plans.
|
||||||
|
* Replaces the large form in PlanModal with guided step-by-step editing.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Basics - Name, code, description, active status
|
||||||
|
* 2. Pricing - Monthly/yearly prices, trial days, transaction fees
|
||||||
|
* 3. Features - Feature picker for capabilities and limits
|
||||||
|
* 4. Display - Visibility, marketing features, Stripe integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
DollarSign,
|
||||||
|
Check,
|
||||||
|
Star,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Modal, Alert } from '../../components/ui';
|
||||||
|
import { FeaturePicker } from './FeaturePicker';
|
||||||
|
import {
|
||||||
|
useFeatures,
|
||||||
|
useAddOnProducts,
|
||||||
|
useCreatePlan,
|
||||||
|
useCreatePlanVersion,
|
||||||
|
useUpdatePlan,
|
||||||
|
useUpdatePlanVersion,
|
||||||
|
useForceUpdatePlanVersion,
|
||||||
|
isForceUpdateConfirmRequired,
|
||||||
|
type PlanFeatureWrite,
|
||||||
|
} from '../../hooks/useBillingAdmin';
|
||||||
|
import { useCurrentUser } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface PlanEditorWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
initialData?: {
|
||||||
|
id?: number;
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
display_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
version?: {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
price_monthly_cents?: number;
|
||||||
|
price_yearly_cents?: number;
|
||||||
|
transaction_fee_percent?: string | number;
|
||||||
|
transaction_fee_fixed_cents?: number;
|
||||||
|
trial_days?: number;
|
||||||
|
is_public?: boolean;
|
||||||
|
is_most_popular?: boolean;
|
||||||
|
show_price?: boolean;
|
||||||
|
marketing_features?: string[];
|
||||||
|
stripe_product_id?: string;
|
||||||
|
stripe_price_id_monthly?: string;
|
||||||
|
stripe_price_id_yearly?: string;
|
||||||
|
features?: Array<{
|
||||||
|
feature: { code: string };
|
||||||
|
bool_value: boolean | null;
|
||||||
|
int_value: number | null;
|
||||||
|
}>;
|
||||||
|
subscriber_count?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardStep = 'basics' | 'pricing' | 'features' | 'display';
|
||||||
|
|
||||||
|
interface WizardFormData {
|
||||||
|
// Plan fields
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
display_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
// Version fields
|
||||||
|
version_name: string;
|
||||||
|
price_monthly_cents: number;
|
||||||
|
price_yearly_cents: number;
|
||||||
|
transaction_fee_percent: number;
|
||||||
|
transaction_fee_fixed_cents: number;
|
||||||
|
trial_days: number;
|
||||||
|
is_public: boolean;
|
||||||
|
is_most_popular: boolean;
|
||||||
|
show_price: boolean;
|
||||||
|
marketing_features: string[];
|
||||||
|
stripe_product_id: string;
|
||||||
|
stripe_price_id_monthly: string;
|
||||||
|
stripe_price_id_yearly: string;
|
||||||
|
selectedFeatures: PlanFeatureWrite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
initialData,
|
||||||
|
}) => {
|
||||||
|
const { data: features, isLoading: featuresLoading } = useFeatures();
|
||||||
|
const { data: addons } = useAddOnProducts();
|
||||||
|
const { data: currentUser } = useCurrentUser();
|
||||||
|
const createPlanMutation = useCreatePlan();
|
||||||
|
const createVersionMutation = useCreatePlanVersion();
|
||||||
|
const updatePlanMutation = useUpdatePlan();
|
||||||
|
const updateVersionMutation = useUpdatePlanVersion();
|
||||||
|
const forceUpdateMutation = useForceUpdatePlanVersion();
|
||||||
|
|
||||||
|
const isNewPlan = mode === 'create';
|
||||||
|
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
|
||||||
|
const isSuperuser = currentUser?.role === 'superuser';
|
||||||
|
|
||||||
|
// Force update state (for updating without creating new version)
|
||||||
|
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
|
||||||
|
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
|
||||||
|
const [newMarketingFeature, setNewMarketingFeature] = useState('');
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const [formData, setFormData] = useState<WizardFormData>(() => ({
|
||||||
|
// Plan fields
|
||||||
|
code: initialData?.code || '',
|
||||||
|
name: initialData?.name || '',
|
||||||
|
description: initialData?.description || '',
|
||||||
|
display_order: initialData?.display_order || 0,
|
||||||
|
is_active: initialData?.is_active ?? true,
|
||||||
|
// Version fields
|
||||||
|
version_name: initialData?.version?.name || '',
|
||||||
|
price_monthly_cents: initialData?.version?.price_monthly_cents || 0,
|
||||||
|
price_yearly_cents: initialData?.version?.price_yearly_cents || 0,
|
||||||
|
transaction_fee_percent:
|
||||||
|
typeof initialData?.version?.transaction_fee_percent === 'string'
|
||||||
|
? parseFloat(initialData.version.transaction_fee_percent)
|
||||||
|
: initialData?.version?.transaction_fee_percent || 4.0,
|
||||||
|
transaction_fee_fixed_cents: initialData?.version?.transaction_fee_fixed_cents || 40,
|
||||||
|
trial_days: initialData?.version?.trial_days || 14,
|
||||||
|
is_public: initialData?.version?.is_public ?? true,
|
||||||
|
is_most_popular: initialData?.version?.is_most_popular || false,
|
||||||
|
show_price: initialData?.version?.show_price ?? true,
|
||||||
|
marketing_features: initialData?.version?.marketing_features || [],
|
||||||
|
stripe_product_id: initialData?.version?.stripe_product_id || '',
|
||||||
|
stripe_price_id_monthly: initialData?.version?.stripe_price_id_monthly || '',
|
||||||
|
stripe_price_id_yearly: initialData?.version?.stripe_price_id_yearly || '',
|
||||||
|
selectedFeatures:
|
||||||
|
initialData?.version?.features?.map((f) => ({
|
||||||
|
feature_code: f.feature.code,
|
||||||
|
bool_value: f.bool_value,
|
||||||
|
int_value: f.int_value,
|
||||||
|
})) || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Wizard steps configuration
|
||||||
|
const steps: Array<{ id: WizardStep; label: string; icon: React.ElementType }> = [
|
||||||
|
{ id: 'basics', label: 'Basics', icon: Package },
|
||||||
|
{ id: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||||
|
{ id: 'features', label: 'Features', icon: Check },
|
||||||
|
{ id: 'display', label: 'Display', icon: Star },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
|
||||||
|
const isFirstStep = currentStepIndex === 0;
|
||||||
|
const isLastStep = currentStepIndex === steps.length - 1;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const validateBasics = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (!formData.code.trim()) newErrors.code = 'Plan code is required';
|
||||||
|
if (!formData.name.trim()) newErrors.name = 'Plan name is required';
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePricing = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
if (formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100) {
|
||||||
|
newErrors.transaction_fee_percent = 'Fee must be between 0 and 100';
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const canProceed = useMemo(() => {
|
||||||
|
if (currentStep === 'basics') {
|
||||||
|
return formData.code.trim() !== '' && formData.name.trim() !== '';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [currentStep, formData.code, formData.name]);
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (currentStep === 'basics' && !validateBasics()) return;
|
||||||
|
if (currentStep === 'pricing' && !validatePricing()) return;
|
||||||
|
|
||||||
|
if (!isLastStep) {
|
||||||
|
setCurrentStep(steps[currentStepIndex + 1].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (!isFirstStep) {
|
||||||
|
setCurrentStep(steps[currentStepIndex - 1].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (stepId: WizardStep) => {
|
||||||
|
// Only allow navigating to visited steps or current step
|
||||||
|
const targetIndex = steps.findIndex((s) => s.id === stepId);
|
||||||
|
if (targetIndex <= currentStepIndex || canProceed) {
|
||||||
|
setCurrentStep(stepId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form handlers
|
||||||
|
const updateCode = (value: string) => {
|
||||||
|
// Sanitize: lowercase, no spaces, only alphanumeric and hyphens/underscores
|
||||||
|
const sanitized = value.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||||
|
setFormData((prev) => ({ ...prev, code: sanitized }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMarketingFeature = () => {
|
||||||
|
if (newMarketingFeature.trim()) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
marketing_features: [...prev.marketing_features, newMarketingFeature.trim()],
|
||||||
|
}));
|
||||||
|
setNewMarketingFeature('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMarketingFeature = (index: number) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
marketing_features: prev.marketing_features.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateBasics() || !validatePricing()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNewPlan) {
|
||||||
|
// Create Plan first
|
||||||
|
await createPlanMutation.mutateAsync({
|
||||||
|
code: formData.code,
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
display_order: formData.display_order,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create first version
|
||||||
|
await createVersionMutation.mutateAsync({
|
||||||
|
plan_code: formData.code,
|
||||||
|
name: formData.version_name || `${formData.name} v1`,
|
||||||
|
is_public: formData.is_public,
|
||||||
|
price_monthly_cents: formData.price_monthly_cents,
|
||||||
|
price_yearly_cents: formData.price_yearly_cents,
|
||||||
|
transaction_fee_percent: formData.transaction_fee_percent,
|
||||||
|
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||||
|
trial_days: formData.trial_days,
|
||||||
|
is_most_popular: formData.is_most_popular,
|
||||||
|
show_price: formData.show_price,
|
||||||
|
marketing_features: formData.marketing_features,
|
||||||
|
stripe_product_id: formData.stripe_product_id,
|
||||||
|
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||||
|
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||||
|
features: formData.selectedFeatures,
|
||||||
|
});
|
||||||
|
} else if (initialData?.id) {
|
||||||
|
// Update plan
|
||||||
|
await updatePlanMutation.mutateAsync({
|
||||||
|
id: initialData.id,
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
display_order: formData.display_order,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update version if exists
|
||||||
|
if (initialData?.version?.id) {
|
||||||
|
await updateVersionMutation.mutateAsync({
|
||||||
|
id: initialData.version.id,
|
||||||
|
name: formData.version_name,
|
||||||
|
is_public: formData.is_public,
|
||||||
|
price_monthly_cents: formData.price_monthly_cents,
|
||||||
|
price_yearly_cents: formData.price_yearly_cents,
|
||||||
|
transaction_fee_percent: formData.transaction_fee_percent,
|
||||||
|
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||||
|
trial_days: formData.trial_days,
|
||||||
|
is_most_popular: formData.is_most_popular,
|
||||||
|
show_price: formData.show_price,
|
||||||
|
marketing_features: formData.marketing_features,
|
||||||
|
stripe_product_id: formData.stripe_product_id,
|
||||||
|
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||||
|
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||||
|
features: formData.selectedFeatures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save plan:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force update handler (updates existing version without creating new one)
|
||||||
|
const handleForceUpdate = async () => {
|
||||||
|
if (!initialData?.version?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setForceUpdateError(null);
|
||||||
|
|
||||||
|
// First call without confirm to get affected subscriber count
|
||||||
|
const response = await forceUpdateMutation.mutateAsync({
|
||||||
|
id: initialData.version.id,
|
||||||
|
name: formData.version_name,
|
||||||
|
is_public: formData.is_public,
|
||||||
|
price_monthly_cents: formData.price_monthly_cents,
|
||||||
|
price_yearly_cents: formData.price_yearly_cents,
|
||||||
|
transaction_fee_percent: formData.transaction_fee_percent,
|
||||||
|
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
|
||||||
|
trial_days: formData.trial_days,
|
||||||
|
is_most_popular: formData.is_most_popular,
|
||||||
|
show_price: formData.show_price,
|
||||||
|
marketing_features: formData.marketing_features,
|
||||||
|
stripe_product_id: formData.stripe_product_id,
|
||||||
|
stripe_price_id_monthly: formData.stripe_price_id_monthly,
|
||||||
|
stripe_price_id_yearly: formData.stripe_price_id_yearly,
|
||||||
|
features: formData.selectedFeatures,
|
||||||
|
confirm: true, // Confirm immediately since user already acknowledged
|
||||||
|
});
|
||||||
|
|
||||||
|
// If successful, close the modal
|
||||||
|
if (!isForceUpdateConfirmRequired(response)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to force update plan:', error);
|
||||||
|
setForceUpdateError('Failed to update plan. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
createPlanMutation.isPending ||
|
||||||
|
createVersionMutation.isPending ||
|
||||||
|
updatePlanMutation.isPending ||
|
||||||
|
updateVersionMutation.isPending ||
|
||||||
|
forceUpdateMutation.isPending;
|
||||||
|
|
||||||
|
// Derived values for display
|
||||||
|
const monthlyEquivalent = formData.price_yearly_cents > 0
|
||||||
|
? (formData.price_yearly_cents / 12 / 100).toFixed(2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const transactionFeeExample = () => {
|
||||||
|
const percent = formData.transaction_fee_percent / 100;
|
||||||
|
const fixed = formData.transaction_fee_fixed_cents / 100;
|
||||||
|
const total = (100 * percent + fixed).toFixed(2);
|
||||||
|
return `On a $100 transaction: $${total} fee`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fee validation warning
|
||||||
|
const feeError =
|
||||||
|
formData.transaction_fee_percent < 0 || formData.transaction_fee_percent > 100
|
||||||
|
? 'Fee must be between 0 and 100'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isNewPlan ? 'Create New Plan' : `Edit ${initialData?.name || 'Plan'}`}
|
||||||
|
size="4xl"
|
||||||
|
>
|
||||||
|
{/* Grandfathering Warning */}
|
||||||
|
{hasSubscribers && !showForceUpdateConfirm && (
|
||||||
|
<Alert
|
||||||
|
variant="warning"
|
||||||
|
className="mb-4"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This version has <strong>{initialData?.version?.subscriber_count}</strong> active
|
||||||
|
subscriber(s). Saving will create a new version (grandfathering). Existing subscribers
|
||||||
|
keep their current plan.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Force Update Confirmation Dialog */}
|
||||||
|
{showForceUpdateConfirm && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||||
|
Warning: This will affect existing customers
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
|
||||||
|
You are about to update this plan version <strong>in place</strong>. This will immediately
|
||||||
|
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
|
||||||
|
subscriber(s). This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||||
|
Only use this for correcting errors or minor adjustments. For significant changes,
|
||||||
|
use the standard save which creates a new version and grandfathers existing subscribers.
|
||||||
|
</p>
|
||||||
|
{forceUpdateError && (
|
||||||
|
<Alert variant="error" message={forceUpdateError} className="mb-3" />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForceUpdateConfirm(false);
|
||||||
|
setForceUpdateError(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleForceUpdate}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
Yes, Update All Subscribers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isActive = step.id === currentStep;
|
||||||
|
const isCompleted = index < currentStepIndex;
|
||||||
|
const StepIcon = step.icon;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
{index > 0 && (
|
||||||
|
<div
|
||||||
|
className={`h-px w-8 ${
|
||||||
|
isCompleted ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goToStep(step.id)}
|
||||||
|
aria-label={step.label}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: isCompleted
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StepIcon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{step.label}</span>
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Main Form Area */}
|
||||||
|
<div className="flex-1 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* Step 1: Basics */}
|
||||||
|
{currentStep === 'basics' && (
|
||||||
|
<div className="space-y-4 p-1">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="plan-code"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Plan Code *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="plan-code"
|
||||||
|
type="text"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => updateCode(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!isNewPlan}
|
||||||
|
placeholder="e.g., starter, pro, enterprise"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{errors.code && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{errors.code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="plan-name"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Display Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="plan-name"
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Starter, Professional, Enterprise"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="plan-description"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="plan-description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||||
|
}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Brief description of this plan..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Active (available for purchase)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Pricing */}
|
||||||
|
{currentStep === 'pricing' && (
|
||||||
|
<div className="space-y-6 p-1">
|
||||||
|
{/* Subscription Pricing */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" /> Subscription Pricing
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="price-monthly"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Monthly Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="price-monthly"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price_monthly_cents / 100}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price_monthly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="price-yearly"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Yearly Price ($)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="price-yearly"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price_yearly_cents / 100}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
price_yearly_cents: Math.round(parseFloat(e.target.value || '0') * 100),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
{monthlyEquivalent && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
=${monthlyEquivalent}/mo equivalent
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="trial-days"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Trial Days
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="trial-days"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.trial_days}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
trial_days: parseInt(e.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transaction Fees */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Transaction Fees
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="fee-percent"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Fee Percentage (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fee-percent"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={formData.transaction_fee_percent}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
transaction_fee_percent: parseFloat(e.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
{feeError && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{feeError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="fee-fixed"
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||||
|
>
|
||||||
|
Fixed Fee (cents)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fee-fixed"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.transaction_fee_fixed_cents}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
transaction_fee_fixed_cents: parseInt(e.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{transactionFeeExample()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Features */}
|
||||||
|
{currentStep === 'features' && (
|
||||||
|
<div className="p-1">
|
||||||
|
{featuresLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FeaturePicker
|
||||||
|
features={features || []}
|
||||||
|
selectedFeatures={formData.selectedFeatures}
|
||||||
|
onChange={(selected) =>
|
||||||
|
setFormData((prev) => ({ ...prev, selectedFeatures: selected }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Display */}
|
||||||
|
{currentStep === 'display' && (
|
||||||
|
<div className="space-y-6 p-1">
|
||||||
|
{/* Visibility */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Visibility Settings
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, is_public: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Show on pricing page
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_most_popular}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, is_most_popular: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
"Most Popular" badge
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.show_price}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, show_price: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Display price</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marketing Features */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Marketing Feature List
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Bullet points shown on pricing page. Separate from actual feature access.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{formData.marketing_features.map((feature, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMarketingFeature(index)}
|
||||||
|
className="text-gray-400 hover:text-red-500 p-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMarketingFeature}
|
||||||
|
onChange={(e) => setNewMarketingFeature(e.target.value)}
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
e.key === 'Enter' && (e.preventDefault(), addMarketingFeature())
|
||||||
|
}
|
||||||
|
placeholder="e.g., Unlimited appointments"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMarketingFeature}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Stripe Integration
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Product ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.stripe_product_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, stripe_product_id: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="prod_..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Monthly Price ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.stripe_price_id_monthly}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
stripe_price_id_monthly: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="price_..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Yearly Price ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.stripe_price_id_yearly}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, stripe_price_id_yearly: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="price_..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Summary Panel */}
|
||||||
|
<div className="w-64 border-l border-gray-200 dark:border-gray-700 pl-6 hidden lg:block">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Plan Summary</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Name:</span>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.name || '(not set)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{formData.price_monthly_cents > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Price:</span>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
${(formData.price_monthly_cents / 100).toFixed(2)}/mo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Features:</span>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.selectedFeatures.length} feature
|
||||||
|
{formData.selectedFeatures.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Status:</span>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{formData.is_active ? 'Active' : 'Inactive'}
|
||||||
|
{formData.is_public ? ', Public' : ', Hidden'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
{!isFirstStep && !showForceUpdateConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPrev}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!showForceUpdateConfirm && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{!isLastStep ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Force Update button - only for superusers editing plans with subscribers */}
|
||||||
|
{hasSubscribers && isSuperuser && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForceUpdateConfirm(true)}
|
||||||
|
disabled={isLoading || !canProceed}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Update Without Versioning
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading || !canProceed}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* Tests for CatalogListPanel Component
|
||||||
|
*
|
||||||
|
* TDD: Tests for the sidebar catalog list with search and filtering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { CatalogListPanel, type CatalogItem } from '../CatalogListPanel';
|
||||||
|
|
||||||
|
// Sample plan data
|
||||||
|
const mockPlans: CatalogItem[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'plan',
|
||||||
|
code: 'free',
|
||||||
|
name: 'Free',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: true,
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 0,
|
||||||
|
priceYearly: 0,
|
||||||
|
subscriberCount: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'plan',
|
||||||
|
code: 'starter',
|
||||||
|
name: 'Starter',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: true,
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 2900,
|
||||||
|
priceYearly: 29000,
|
||||||
|
subscriberCount: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'plan',
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Professional',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: true,
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 7900,
|
||||||
|
priceYearly: 79000,
|
||||||
|
subscriberCount: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: 'plan',
|
||||||
|
code: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: false, // Hidden plan
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 19900,
|
||||||
|
priceYearly: 199000,
|
||||||
|
subscriberCount: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: 'plan',
|
||||||
|
code: 'legacy_pro',
|
||||||
|
name: 'Pro (Legacy)',
|
||||||
|
isActive: false, // Inactive
|
||||||
|
isPublic: false,
|
||||||
|
isLegacy: true,
|
||||||
|
priceMonthly: 4900,
|
||||||
|
priceYearly: 49000,
|
||||||
|
subscriberCount: 15,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample add-on data
|
||||||
|
const mockAddons: CatalogItem[] = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
type: 'addon',
|
||||||
|
code: 'sms_pack',
|
||||||
|
name: 'SMS Credits Pack',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: true,
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
type: 'addon',
|
||||||
|
code: 'api_access',
|
||||||
|
name: 'API Access',
|
||||||
|
isActive: true,
|
||||||
|
isPublic: true,
|
||||||
|
isLegacy: false,
|
||||||
|
priceMonthly: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
type: 'addon',
|
||||||
|
code: 'old_addon',
|
||||||
|
name: 'Deprecated Add-on',
|
||||||
|
isActive: false,
|
||||||
|
isPublic: false,
|
||||||
|
isLegacy: true,
|
||||||
|
priceMonthly: 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const allItems = [...mockPlans, ...mockAddons];
|
||||||
|
|
||||||
|
describe('CatalogListPanel', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
items: allItems,
|
||||||
|
selectedId: null,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onCreatePlan: vi.fn(),
|
||||||
|
onCreateAddon: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders all items by default', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Should show all plans
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show all addons
|
||||||
|
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows item code as badge', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('starter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('sms_pack')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows price for items', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Free plan shows "Free/mo" - use getAllByText since "free" appears multiple times
|
||||||
|
const freeElements = screen.getAllByText(/free/i);
|
||||||
|
expect(freeElements.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Starter plan shows $29.00/mo
|
||||||
|
expect(screen.getByText(/\$29/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows type badges for plans and add-ons', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Should have Base Plan badges for plans
|
||||||
|
const baseBadges = screen.getAllByText(/base/i);
|
||||||
|
expect(baseBadges.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should have Add-on badges
|
||||||
|
const addonBadges = screen.getAllByText(/add-on/i);
|
||||||
|
expect(addonBadges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows status badges for inactive items', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Legacy plan should show inactive badge
|
||||||
|
const inactiveBadges = screen.getAllByText(/inactive/i);
|
||||||
|
expect(inactiveBadges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows legacy badge for legacy items', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const legacyBadges = screen.getAllByText(/legacy/i);
|
||||||
|
expect(legacyBadges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows hidden badge for non-public items', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const hiddenBadges = screen.getAllByText(/hidden/i);
|
||||||
|
expect(hiddenBadges.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Filtering', () => {
|
||||||
|
it('filters to show only base plans', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Click the "Base Plans" filter
|
||||||
|
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||||
|
await user.selectOptions(typeFilter, 'plan');
|
||||||
|
|
||||||
|
// Should show plans
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show addons
|
||||||
|
expect(screen.queryByText('SMS Credits Pack')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to show only add-ons', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||||
|
await user.selectOptions(typeFilter, 'addon');
|
||||||
|
|
||||||
|
// Should show addons
|
||||||
|
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show plans
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all types when "All" is selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// First filter to plans only
|
||||||
|
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||||
|
await user.selectOptions(typeFilter, 'plan');
|
||||||
|
|
||||||
|
// Then select "All"
|
||||||
|
await user.selectOptions(typeFilter, 'all');
|
||||||
|
|
||||||
|
// Should show both
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Status Filtering', () => {
|
||||||
|
it('filters to show only active items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||||
|
await user.selectOptions(statusFilter, 'active');
|
||||||
|
|
||||||
|
// Should show active items
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show inactive items
|
||||||
|
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to show only inactive items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||||
|
await user.selectOptions(statusFilter, 'inactive');
|
||||||
|
|
||||||
|
// Should show inactive items
|
||||||
|
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Deprecated Add-on')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show active items
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visibility Filtering', () => {
|
||||||
|
it('filters to show only public items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
|
||||||
|
await user.selectOptions(visibilityFilter, 'public');
|
||||||
|
|
||||||
|
// Should show public items
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show hidden items
|
||||||
|
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to show only hidden items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const visibilityFilter = screen.getByRole('combobox', { name: /visibility/i });
|
||||||
|
await user.selectOptions(visibilityFilter, 'hidden');
|
||||||
|
|
||||||
|
// Should show hidden items
|
||||||
|
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show public items
|
||||||
|
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Legacy Filtering', () => {
|
||||||
|
it('filters to show only current (non-legacy) items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
|
||||||
|
await user.selectOptions(legacyFilter, 'current');
|
||||||
|
|
||||||
|
// Should show current items
|
||||||
|
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show legacy items
|
||||||
|
expect(screen.queryByText('Pro (Legacy)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to show only legacy items', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const legacyFilter = screen.getByRole('combobox', { name: /legacy/i });
|
||||||
|
await user.selectOptions(legacyFilter, 'legacy');
|
||||||
|
|
||||||
|
// Should show legacy items
|
||||||
|
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show current items
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
it('filters items by name when searching', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
|
await user.type(searchInput, 'starter');
|
||||||
|
|
||||||
|
// Should show Starter plan
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show other items
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Professional')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters items by code when searching', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
|
await user.type(searchInput, 'sms_pack');
|
||||||
|
|
||||||
|
// Should show SMS Credits Pack
|
||||||
|
expect(screen.getByText('SMS Credits Pack')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show other items
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no results message when search has no matches', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
|
await user.type(searchInput, 'nonexistent');
|
||||||
|
|
||||||
|
expect(screen.getByText(/no items found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search is case-insensitive', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
|
await user.type(searchInput, 'STARTER');
|
||||||
|
|
||||||
|
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selection', () => {
|
||||||
|
it('calls onSelect when an item is clicked', async () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} onSelect={onSelect} />);
|
||||||
|
|
||||||
|
const starterItem = screen.getByText('Starter').closest('button');
|
||||||
|
await user.click(starterItem!);
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
id: 2,
|
||||||
|
code: 'starter',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the selected item', () => {
|
||||||
|
render(<CatalogListPanel {...defaultProps} selectedId={2} />);
|
||||||
|
|
||||||
|
// The selected item should have a different style
|
||||||
|
const starterItem = screen.getByText('Starter').closest('button');
|
||||||
|
expect(starterItem).toHaveClass('bg-blue-50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create Actions', () => {
|
||||||
|
it('calls onCreatePlan when "Create Plan" is clicked', async () => {
|
||||||
|
const onCreatePlan = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} onCreatePlan={onCreatePlan} />);
|
||||||
|
|
||||||
|
const createPlanButton = screen.getByRole('button', { name: /create plan/i });
|
||||||
|
await user.click(createPlanButton);
|
||||||
|
|
||||||
|
expect(onCreatePlan).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCreateAddon when "Create Add-on" is clicked', async () => {
|
||||||
|
const onCreateAddon = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} onCreateAddon={onCreateAddon} />);
|
||||||
|
|
||||||
|
const createAddonButton = screen.getByRole('button', { name: /create add-on/i });
|
||||||
|
await user.click(createAddonButton);
|
||||||
|
|
||||||
|
expect(onCreateAddon).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combined Filters', () => {
|
||||||
|
it('combines type and status filters', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Filter to inactive plans only
|
||||||
|
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||||
|
const statusFilter = screen.getByRole('combobox', { name: /status/i });
|
||||||
|
|
||||||
|
await user.selectOptions(typeFilter, 'plan');
|
||||||
|
await user.selectOptions(statusFilter, 'inactive');
|
||||||
|
|
||||||
|
// Should only show inactive plans
|
||||||
|
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show active plans or addons
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Deprecated Add-on')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines search with filters', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CatalogListPanel {...defaultProps} />);
|
||||||
|
|
||||||
|
// Filter to plans and search for "pro"
|
||||||
|
const typeFilter = screen.getByRole('combobox', { name: /type/i });
|
||||||
|
await user.selectOptions(typeFilter, 'plan');
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||||
|
await user.type(searchInput, 'pro');
|
||||||
|
|
||||||
|
// Should show Professional and Pro (Legacy)
|
||||||
|
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro (Legacy)')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should NOT show other items
|
||||||
|
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FeaturePicker Component
|
||||||
|
*
|
||||||
|
* TDD: These tests define the expected behavior of the FeaturePicker component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { FeaturePicker, FeaturePickerProps } from '../FeaturePicker';
|
||||||
|
import { FEATURE_CATALOG, BOOLEAN_FEATURES, INTEGER_FEATURES } from '../../featureCatalog';
|
||||||
|
import type { PlanFeatureWrite } from '../../../hooks/useBillingAdmin';
|
||||||
|
|
||||||
|
// Mock features from API (similar to what useFeatures() returns)
|
||||||
|
const mockApiFeatures = [
|
||||||
|
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'Allow SMS', feature_type: 'boolean' as const },
|
||||||
|
{ id: 2, code: 'email_enabled', name: 'Email Enabled', description: 'Allow email', feature_type: 'boolean' as const },
|
||||||
|
{ id: 3, code: 'max_users', name: 'Maximum Users', description: 'Max users limit', feature_type: 'integer' as const },
|
||||||
|
{ id: 4, code: 'max_resources', name: 'Maximum Resources', description: 'Max resources', feature_type: 'integer' as const },
|
||||||
|
{ id: 5, code: 'custom_feature', name: 'Custom Feature', description: 'Not in catalog', feature_type: 'boolean' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper component that manages state for controlled FeaturePicker
|
||||||
|
*/
|
||||||
|
const StatefulFeaturePicker: React.FC<
|
||||||
|
Omit<FeaturePickerProps, 'selectedFeatures' | 'onChange'> & {
|
||||||
|
initialSelectedFeatures?: PlanFeatureWrite[];
|
||||||
|
onChangeCapture?: (features: PlanFeatureWrite[]) => void;
|
||||||
|
}
|
||||||
|
> = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => {
|
||||||
|
const [selectedFeatures, setSelectedFeatures] = useState<PlanFeatureWrite[]>(initialSelectedFeatures);
|
||||||
|
|
||||||
|
const handleChange = (features: PlanFeatureWrite[]) => {
|
||||||
|
setSelectedFeatures(features);
|
||||||
|
onChangeCapture?.(features);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeaturePicker
|
||||||
|
{...props}
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('FeaturePicker', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
features: mockApiFeatures,
|
||||||
|
selectedFeatures: [],
|
||||||
|
onChange: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders boolean features in Capabilities section', () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Capabilities')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders integer features in Limits & Quotas section', () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Limits & Quotas')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Maximum Users')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Maximum Resources')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows selected features as checked', () => {
|
||||||
|
const selectedFeatures = [
|
||||||
|
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows integer values for selected integer features', () => {
|
||||||
|
const selectedFeatures = [
|
||||||
|
{ feature_code: 'max_users', bool_value: null, int_value: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('50');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Feature Selection', () => {
|
||||||
|
it('calls onChange when a boolean feature is selected', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([
|
||||||
|
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when a boolean feature is deselected', async () => {
|
||||||
|
const selectedFeatures = [
|
||||||
|
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||||
|
];
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when an integer feature is selected with default value 0', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox', { name: /maximum users/i });
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([
|
||||||
|
{ feature_code: 'max_users', bool_value: null, int_value: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when integer value is updated', async () => {
|
||||||
|
const initialSelectedFeatures = [
|
||||||
|
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||||
|
];
|
||||||
|
const onChangeCapture = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<StatefulFeaturePicker
|
||||||
|
features={mockApiFeatures}
|
||||||
|
initialSelectedFeatures={initialSelectedFeatures}
|
||||||
|
onChangeCapture={onChangeCapture}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('10');
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, '50');
|
||||||
|
|
||||||
|
// Should have been called multiple times as user types
|
||||||
|
expect(onChangeCapture).toHaveBeenCalled();
|
||||||
|
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||||
|
expect(lastCall).toContainEqual({ feature_code: 'max_users', bool_value: null, int_value: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Canonical Catalog Validation', () => {
|
||||||
|
it('shows warning badge for features not in canonical catalog', () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
// custom_feature is not in the canonical catalog
|
||||||
|
const customFeatureRow = screen.getByText('Custom Feature').closest('label');
|
||||||
|
expect(customFeatureRow).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show a warning indicator
|
||||||
|
const warningIndicator = within(customFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||||
|
expect(warningIndicator).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show warning for canonical features', () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
// sms_enabled is in the canonical catalog
|
||||||
|
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
|
||||||
|
expect(smsFeatureRow).toBeInTheDocument();
|
||||||
|
|
||||||
|
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||||
|
expect(warningIndicator).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search Functionality', () => {
|
||||||
|
it('filters features when search term is entered', async () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||||
|
await userEvent.type(searchInput, 'sms');
|
||||||
|
|
||||||
|
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Email Enabled')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Maximum Users')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no results message when search has no matches', async () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||||
|
await userEvent.type(searchInput, 'nonexistent');
|
||||||
|
|
||||||
|
expect(screen.getByText(/no features found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears search when clear button is clicked', async () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||||
|
await userEvent.type(searchInput, 'sms');
|
||||||
|
|
||||||
|
const clearButton = screen.getByRole('button', { name: /clear search/i });
|
||||||
|
await userEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(searchInput).toHaveValue('');
|
||||||
|
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Payload Shape', () => {
|
||||||
|
it('produces correct payload shape for boolean features', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /email enabled/i }));
|
||||||
|
|
||||||
|
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||||
|
|
||||||
|
// Verify payload shape matches PlanFeatureWrite interface
|
||||||
|
expect(lastCall).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
feature_code: expect.any(String),
|
||||||
|
bool_value: expect.any(Boolean),
|
||||||
|
int_value: null,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces correct payload shape for integer features', async () => {
|
||||||
|
const selectedFeatures = [
|
||||||
|
{ feature_code: 'max_users', bool_value: null, int_value: 25 },
|
||||||
|
];
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('25');
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, '100');
|
||||||
|
|
||||||
|
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||||
|
|
||||||
|
expect(lastCall).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
feature_code: 'max_users',
|
||||||
|
bool_value: null,
|
||||||
|
int_value: expect.any(Number),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces correct payload shape for mixed selection', async () => {
|
||||||
|
const onChangeCapture = vi.fn();
|
||||||
|
render(
|
||||||
|
<StatefulFeaturePicker
|
||||||
|
features={mockApiFeatures}
|
||||||
|
onChangeCapture={onChangeCapture}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select a boolean feature
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||||
|
// Select an integer feature
|
||||||
|
await userEvent.click(screen.getByRole('checkbox', { name: /maximum users/i }));
|
||||||
|
|
||||||
|
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||||
|
|
||||||
|
expect(lastCall).toHaveLength(2);
|
||||||
|
expect(lastCall).toContainEqual({
|
||||||
|
feature_code: 'sms_enabled',
|
||||||
|
bool_value: true,
|
||||||
|
int_value: null,
|
||||||
|
});
|
||||||
|
expect(lastCall).toContainEqual({
|
||||||
|
feature_code: 'max_users',
|
||||||
|
bool_value: null,
|
||||||
|
int_value: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('has accessible labels for all checkboxes', () => {
|
||||||
|
render(<FeaturePicker {...defaultProps} />);
|
||||||
|
|
||||||
|
// Each feature should have an accessible checkbox
|
||||||
|
mockApiFeatures.forEach((feature) => {
|
||||||
|
const checkbox = screen.getByRole('checkbox', { name: new RegExp(feature.name, 'i') });
|
||||||
|
expect(checkbox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('integer input has accessible label', () => {
|
||||||
|
const selectedFeatures = [
|
||||||
|
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('10');
|
||||||
|
expect(input).toHaveAttribute('aria-label');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* Tests for PlanEditorWizard Component Validation
|
||||||
|
*
|
||||||
|
* TDD: These tests define the expected validation behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { PlanEditorWizard } from '../PlanEditorWizard';
|
||||||
|
|
||||||
|
// Create a fresh query client for each test
|
||||||
|
const createTestQueryClient = () =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrapper with QueryClientProvider
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the hooks
|
||||||
|
vi.mock('../../../hooks/useBillingAdmin', () => ({
|
||||||
|
useFeatures: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 1, code: 'sms_enabled', name: 'SMS', feature_type: 'boolean' },
|
||||||
|
{ id: 2, code: 'max_users', name: 'Max Users', feature_type: 'integer' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
useAddOnProducts: () => ({
|
||||||
|
data: [{ id: 1, code: 'addon1', name: 'Add-on 1', is_active: true }],
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
useCreatePlan: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({ id: 1, code: 'test' }),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useCreatePlanVersion: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({ id: 1, version: 1 }),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useUpdatePlan: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useUpdatePlanVersion: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
useForceUpdatePlanVersion: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
isForceUpdateConfirmRequired: (response: unknown) =>
|
||||||
|
response !== null &&
|
||||||
|
typeof response === 'object' &&
|
||||||
|
'requires_confirm' in response &&
|
||||||
|
(response as { requires_confirm: boolean }).requires_confirm === true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useCurrentUser from useAuth
|
||||||
|
vi.mock('../../../hooks/useAuth', () => ({
|
||||||
|
useCurrentUser: () => ({
|
||||||
|
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PlanEditorWizard', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
mode: 'create' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basics Step Validation', () => {
|
||||||
|
it('requires plan name to proceed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Plan code is entered but name is empty
|
||||||
|
const codeInput = screen.getByLabelText(/plan code/i);
|
||||||
|
await user.type(codeInput, 'test_plan');
|
||||||
|
|
||||||
|
// Try to click Next
|
||||||
|
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||||
|
expect(nextButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires plan code to proceed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Name is entered but code is empty
|
||||||
|
const nameInput = screen.getByLabelText(/display name/i);
|
||||||
|
await user.type(nameInput, 'Test Plan');
|
||||||
|
|
||||||
|
// Next button should be disabled
|
||||||
|
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||||
|
expect(nextButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows proceeding when code and name are provided', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Enter both code and name
|
||||||
|
const codeInput = screen.getByLabelText(/plan code/i);
|
||||||
|
const nameInput = screen.getByLabelText(/display name/i);
|
||||||
|
|
||||||
|
await user.type(codeInput, 'test_plan');
|
||||||
|
await user.type(nameInput, 'Test Plan');
|
||||||
|
|
||||||
|
// Next button should be enabled
|
||||||
|
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes plan code to lowercase with no spaces', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const codeInput = screen.getByLabelText(/plan code/i);
|
||||||
|
await user.type(codeInput, 'My Test Plan');
|
||||||
|
|
||||||
|
// Should be sanitized
|
||||||
|
expect(codeInput).toHaveValue('mytestplan');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pricing Step Validation', () => {
|
||||||
|
const goToPricingStep = async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Fill basics
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
|
||||||
|
// Go to pricing step
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows pricing step inputs', async () => {
|
||||||
|
await goToPricingStep();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/yearly price/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow negative monthly price', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||||
|
await user.clear(monthlyInput);
|
||||||
|
await user.type(monthlyInput, '-50');
|
||||||
|
|
||||||
|
// Should show validation error or prevent input
|
||||||
|
// The input type="number" with min="0" should prevent negative values
|
||||||
|
expect(monthlyInput).toHaveAttribute('min', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow negative yearly price', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||||
|
await user.clear(yearlyInput);
|
||||||
|
await user.type(yearlyInput, '-100');
|
||||||
|
|
||||||
|
// Should have min attribute set
|
||||||
|
expect(yearlyInput).toHaveAttribute('min', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays derived monthly equivalent for yearly price', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||||
|
await user.clear(yearlyInput);
|
||||||
|
await user.type(yearlyInput, '120');
|
||||||
|
|
||||||
|
// Should show the monthly equivalent ($10/mo)
|
||||||
|
expect(screen.getByText(/\$10.*mo/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Transaction Fees Validation', () => {
|
||||||
|
const goToPricingStep = async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Fill basics and navigate
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||||
|
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('validates fee percent is between 0 and 100', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||||
|
|
||||||
|
// Should have min and max attributes
|
||||||
|
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||||
|
expect(feePercentInput).toHaveAttribute('max', '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow fee percent over 100', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||||
|
await user.clear(feePercentInput);
|
||||||
|
await user.type(feePercentInput, '150');
|
||||||
|
|
||||||
|
// Should show validation warning
|
||||||
|
expect(screen.getByText(/must be between 0 and 100/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not allow negative fee percent', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||||
|
|
||||||
|
// Input has min="0" attribute to prevent negative values
|
||||||
|
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows transaction fee example calculation', async () => {
|
||||||
|
const user = await goToPricingStep();
|
||||||
|
|
||||||
|
// Should show example like "On a $100 transaction: $4.40 fee"
|
||||||
|
expect(screen.getByText(/on a.*transaction/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Wizard Navigation', () => {
|
||||||
|
it('shows all wizard steps', () => {
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Should show step indicators (they have aria-label)
|
||||||
|
expect(screen.getByRole('button', { name: /basics/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /pricing/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /features/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /display/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates back from pricing to basics', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Fill basics and go to pricing
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||||
|
|
||||||
|
// Should be on pricing step
|
||||||
|
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click back
|
||||||
|
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||||
|
|
||||||
|
// Should be back on basics
|
||||||
|
expect(screen.getByLabelText(/plan code/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows clicking step indicators to navigate', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Fill basics
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
|
||||||
|
// Click on Pricing step indicator
|
||||||
|
const pricingStep = screen.getByRole('button', { name: /pricing/i });
|
||||||
|
await user.click(pricingStep);
|
||||||
|
|
||||||
|
// Should navigate to pricing
|
||||||
|
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Live Summary Panel', () => {
|
||||||
|
it('shows plan name in summary', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'My Amazing Plan');
|
||||||
|
|
||||||
|
// Summary should show the plan name
|
||||||
|
expect(screen.getByText('My Amazing Plan')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows price in summary after entering pricing', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Fill basics
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||||
|
|
||||||
|
// Enter price
|
||||||
|
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||||
|
await user.clear(monthlyInput);
|
||||||
|
await user.type(monthlyInput, '29');
|
||||||
|
|
||||||
|
// Summary should show the price
|
||||||
|
expect(screen.getByText(/\$29/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows selected features count in summary', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Navigate to features step
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i })); // to pricing
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i })); // to features
|
||||||
|
|
||||||
|
// Select a feature
|
||||||
|
const smsCheckbox = screen.getByRole('checkbox', { name: /sms/i });
|
||||||
|
await user.click(smsCheckbox);
|
||||||
|
|
||||||
|
// Summary should show feature count
|
||||||
|
expect(screen.getByText(/1 feature/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create Version Confirmation', () => {
|
||||||
|
it('shows grandfathering warning when editing version with subscribers', async () => {
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
isOpen={true}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 5,
|
||||||
|
name: 'Pro v1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show warning about subscribers and grandfathering
|
||||||
|
expect(screen.getByText(/5/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/subscriber/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/grandfathering/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Create New Version" confirmation for version with subscribers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
{...defaultProps}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to last step and try to save
|
||||||
|
// The save button should mention "Create New Version"
|
||||||
|
const saveButton = screen.queryByRole('button', { name: /create new version/i });
|
||||||
|
expect(saveButton || screen.getByText(/new version/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission', () => {
|
||||||
|
it('calls onClose after successful creation', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard {...defaultProps} onClose={onClose} />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fill all required fields
|
||||||
|
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||||
|
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||||
|
|
||||||
|
// Navigate through wizard
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i })); // pricing
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i })); // features
|
||||||
|
await user.click(screen.getByRole('button', { name: /next/i })); // display
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const createButton = screen.getByRole('button', { name: /create plan/i });
|
||||||
|
await user.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Force Update (Superuser)', () => {
|
||||||
|
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
{...defaultProps}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 5,
|
||||||
|
name: 'Pro v1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to last step
|
||||||
|
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||||
|
|
||||||
|
// Should show "Update Without Versioning" button
|
||||||
|
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
{...defaultProps}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 5,
|
||||||
|
name: 'Pro v1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to last step
|
||||||
|
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||||
|
|
||||||
|
// Click the force update button
|
||||||
|
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||||
|
|
||||||
|
// Should show confirmation dialog with warning
|
||||||
|
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
|
||||||
|
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can cancel force update confirmation', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
{...defaultProps}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 5,
|
||||||
|
name: 'Pro v1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to last step
|
||||||
|
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||||
|
|
||||||
|
// Click the force update button
|
||||||
|
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
|
||||||
|
|
||||||
|
// Click Cancel
|
||||||
|
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
|
||||||
|
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
|
||||||
|
|
||||||
|
// Confirmation dialog should be hidden, back to normal footer
|
||||||
|
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PlanEditorWizard
|
||||||
|
{...defaultProps}
|
||||||
|
mode="edit"
|
||||||
|
initialData={{
|
||||||
|
id: 1,
|
||||||
|
code: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
version: {
|
||||||
|
id: 1,
|
||||||
|
subscriber_count: 0, // No subscribers
|
||||||
|
name: 'Pro v1',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to last step
|
||||||
|
await user.click(screen.getByRole('button', { name: /pricing/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /features/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /display/i }));
|
||||||
|
|
||||||
|
// Should NOT show "Update Without Versioning" button
|
||||||
|
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
445
frontend/src/billing/featureCatalog.ts
Normal file
445
frontend/src/billing/featureCatalog.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* Canonical Feature Catalog
|
||||||
|
*
|
||||||
|
* This file defines the canonical list of features available in the SmoothSchedule
|
||||||
|
* billing system. Features are organized by type (boolean vs integer) and include
|
||||||
|
* human-readable labels and descriptions.
|
||||||
|
*
|
||||||
|
* IMPORTANT: When adding new feature codes, add them here first to maintain a
|
||||||
|
* single source of truth. The FeaturePicker component uses this catalog to
|
||||||
|
* provide autocomplete and validation.
|
||||||
|
*
|
||||||
|
* Feature Types:
|
||||||
|
* - Boolean: On/off capabilities (e.g., sms_enabled, api_access)
|
||||||
|
* - Integer: Limit/quota features (e.g., max_users, max_resources)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```typescript
|
||||||
|
* import { FEATURE_CATALOG, getFeatureInfo, isCanonicalFeature } from '../billing/featureCatalog';
|
||||||
|
*
|
||||||
|
* // Get info about a feature
|
||||||
|
* const info = getFeatureInfo('max_users');
|
||||||
|
* // { code: 'max_users', name: 'Maximum Users', type: 'integer', ... }
|
||||||
|
*
|
||||||
|
* // Check if a feature is in the canonical catalog
|
||||||
|
* const isCanonical = isCanonicalFeature('custom_feature'); // false
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FeatureType = 'boolean' | 'integer';
|
||||||
|
|
||||||
|
export interface FeatureCatalogEntry {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: FeatureType;
|
||||||
|
category: FeatureCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeatureCategory =
|
||||||
|
| 'communication'
|
||||||
|
| 'limits'
|
||||||
|
| 'access'
|
||||||
|
| 'branding'
|
||||||
|
| 'support'
|
||||||
|
| 'integrations'
|
||||||
|
| 'security'
|
||||||
|
| 'scheduling';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Boolean Features (Capabilities)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
|
||||||
|
// Communication
|
||||||
|
{
|
||||||
|
code: 'sms_enabled',
|
||||||
|
name: 'SMS Messaging',
|
||||||
|
description: 'Send SMS notifications and reminders to customers',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'communication',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'masked_calling_enabled',
|
||||||
|
name: 'Masked Calling',
|
||||||
|
description: 'Make calls with masked caller ID for privacy',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'communication',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'proxy_number_enabled',
|
||||||
|
name: 'Proxy Phone Numbers',
|
||||||
|
description: 'Use proxy phone numbers for customer communication',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'communication',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Payments & Commerce
|
||||||
|
{
|
||||||
|
code: 'can_accept_payments',
|
||||||
|
name: 'Accept Payments',
|
||||||
|
description: 'Accept online payments via Stripe Connect',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_pos',
|
||||||
|
name: 'Point of Sale',
|
||||||
|
description: 'Use Point of Sale (POS) system',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scheduling & Booking
|
||||||
|
{
|
||||||
|
code: 'recurring_appointments',
|
||||||
|
name: 'Recurring Appointments',
|
||||||
|
description: 'Schedule recurring appointments',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'scheduling',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'group_bookings',
|
||||||
|
name: 'Group Bookings',
|
||||||
|
description: 'Allow multiple customers per appointment',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'scheduling',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'waitlist',
|
||||||
|
name: 'Waitlist',
|
||||||
|
description: 'Enable waitlist for fully booked slots',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'scheduling',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_add_video_conferencing',
|
||||||
|
name: 'Video Conferencing',
|
||||||
|
description: 'Add video conferencing to events',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'scheduling',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Access & Features
|
||||||
|
{
|
||||||
|
code: 'api_access',
|
||||||
|
name: 'API Access',
|
||||||
|
description: 'Access the public API for integrations',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_analytics',
|
||||||
|
name: 'Analytics Dashboard',
|
||||||
|
description: 'Access business analytics and reporting',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_tasks',
|
||||||
|
name: 'Automated Tasks',
|
||||||
|
description: 'Create and run automated task workflows',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_contracts',
|
||||||
|
name: 'Contracts & E-Signatures',
|
||||||
|
description: 'Create and manage e-signature contracts',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'customer_portal',
|
||||||
|
name: 'Customer Portal',
|
||||||
|
description: 'Branded self-service portal for customers',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'custom_fields',
|
||||||
|
name: 'Custom Fields',
|
||||||
|
description: 'Create custom data fields for resources and events',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_export_data',
|
||||||
|
name: 'Data Export',
|
||||||
|
description: 'Export data (appointments, customers, etc.)',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_mobile_app',
|
||||||
|
name: 'Mobile App',
|
||||||
|
description: 'Access the mobile app for field employees',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'access',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
{
|
||||||
|
code: 'calendar_sync',
|
||||||
|
name: 'Calendar Sync',
|
||||||
|
description: 'Sync with Google Calendar, Outlook, etc.',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'integrations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'webhooks_enabled',
|
||||||
|
name: 'Webhooks',
|
||||||
|
description: 'Send webhook notifications for events',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'integrations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_plugins',
|
||||||
|
name: 'Plugin Integrations',
|
||||||
|
description: 'Use third-party plugin integrations',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'integrations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_create_plugins',
|
||||||
|
name: 'Create Plugins',
|
||||||
|
description: 'Create custom plugins for automation',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'integrations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_manage_oauth_credentials',
|
||||||
|
name: 'Manage OAuth',
|
||||||
|
description: 'Manage your own OAuth credentials',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'integrations',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
{
|
||||||
|
code: 'custom_branding',
|
||||||
|
name: 'Custom Branding',
|
||||||
|
description: 'Customize branding colors, logo, and styling',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'branding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'white_label',
|
||||||
|
name: 'White Label',
|
||||||
|
description: 'Remove SmoothSchedule branding completely',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'branding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_use_custom_domain',
|
||||||
|
name: 'Custom Domain',
|
||||||
|
description: 'Configure a custom domain for your booking page',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'branding',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Support
|
||||||
|
{
|
||||||
|
code: 'priority_support',
|
||||||
|
name: 'Priority Support',
|
||||||
|
description: 'Get priority customer support response',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'support',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Security & Compliance
|
||||||
|
{
|
||||||
|
code: 'can_require_2fa',
|
||||||
|
name: 'Require 2FA',
|
||||||
|
description: 'Require two-factor authentication for users',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'security',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sso_enabled',
|
||||||
|
name: 'Single Sign-On (SSO)',
|
||||||
|
description: 'Enable SSO authentication for team members',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'security',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_delete_data',
|
||||||
|
name: 'Delete Data',
|
||||||
|
description: 'Permanently delete data',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'security',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'can_download_logs',
|
||||||
|
name: 'Download Logs',
|
||||||
|
description: 'Download system logs',
|
||||||
|
type: 'boolean',
|
||||||
|
category: 'security',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Integer Features (Limits & Quotas)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const INTEGER_FEATURES: FeatureCatalogEntry[] = [
|
||||||
|
// User/Resource Limits
|
||||||
|
{
|
||||||
|
code: 'max_users',
|
||||||
|
name: 'Maximum Team Members',
|
||||||
|
description: 'Maximum number of team member accounts (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_resources',
|
||||||
|
name: 'Maximum Resources',
|
||||||
|
description: 'Maximum number of resources (staff, rooms, equipment)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_locations',
|
||||||
|
name: 'Location Limit',
|
||||||
|
description: 'Maximum number of business locations (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_services',
|
||||||
|
name: 'Maximum Services',
|
||||||
|
description: 'Maximum number of service types (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_customers',
|
||||||
|
name: 'Customer Limit',
|
||||||
|
description: 'Maximum number of customer records (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_event_types',
|
||||||
|
name: 'Max Event Types',
|
||||||
|
description: 'Maximum number of event types',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Usage Limits
|
||||||
|
{
|
||||||
|
code: 'max_appointments_per_month',
|
||||||
|
name: 'Monthly Appointment Limit',
|
||||||
|
description: 'Maximum appointments per month (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_automated_tasks',
|
||||||
|
name: 'Automated Task Limit',
|
||||||
|
description: 'Maximum number of automated tasks (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_email_templates',
|
||||||
|
name: 'Email Template Limit',
|
||||||
|
description: 'Maximum number of custom email templates (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_calendars_connected',
|
||||||
|
name: 'Max Calendars',
|
||||||
|
description: 'Maximum number of external calendars connected',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Technical Limits
|
||||||
|
{
|
||||||
|
code: 'storage_gb',
|
||||||
|
name: 'Storage (GB)',
|
||||||
|
description: 'File storage limit in gigabytes (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'max_api_requests_per_day',
|
||||||
|
name: 'Daily API Request Limit',
|
||||||
|
description: 'Maximum API requests per day (0 = unlimited)',
|
||||||
|
type: 'integer',
|
||||||
|
category: 'limits',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Combined Catalog
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const FEATURE_CATALOG: FeatureCatalogEntry[] = [
|
||||||
|
...BOOLEAN_FEATURES,
|
||||||
|
...INTEGER_FEATURES,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a lookup map for quick access
|
||||||
|
const featureMap = new Map<string, FeatureCatalogEntry>(
|
||||||
|
FEATURE_CATALOG.map((f) => [f.code, f])
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get feature information by code
|
||||||
|
*/
|
||||||
|
export const getFeatureInfo = (code: string): FeatureCatalogEntry | undefined => {
|
||||||
|
return featureMap.get(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature code is in the canonical catalog
|
||||||
|
*/
|
||||||
|
export const isCanonicalFeature = (code: string): boolean => {
|
||||||
|
return featureMap.has(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all features by type
|
||||||
|
*/
|
||||||
|
export const getFeaturesByType = (type: FeatureType): FeatureCatalogEntry[] => {
|
||||||
|
return FEATURE_CATALOG.filter((f) => f.type === type);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all features by category
|
||||||
|
*/
|
||||||
|
export const getFeaturesByCategory = (category: FeatureCategory): FeatureCatalogEntry[] => {
|
||||||
|
return FEATURE_CATALOG.filter((f) => f.category === category);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unique categories
|
||||||
|
*/
|
||||||
|
export const getAllCategories = (): FeatureCategory[] => {
|
||||||
|
return [...new Set(FEATURE_CATALOG.map((f) => f.category))];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format category name for display
|
||||||
|
*/
|
||||||
|
export const formatCategoryName = (category: FeatureCategory): string => {
|
||||||
|
const names: Record<FeatureCategory, string> = {
|
||||||
|
communication: 'Communication',
|
||||||
|
limits: 'Limits & Quotas',
|
||||||
|
access: 'Access & Features',
|
||||||
|
branding: 'Branding & Customization',
|
||||||
|
support: 'Support',
|
||||||
|
integrations: 'Integrations',
|
||||||
|
security: 'Security & Compliance',
|
||||||
|
scheduling: 'Scheduling & Booking',
|
||||||
|
};
|
||||||
|
return names[category];
|
||||||
|
};
|
||||||
27
frontend/src/billing/index.ts
Normal file
27
frontend/src/billing/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Billing Module
|
||||||
|
*
|
||||||
|
* Components and utilities for the billing management system.
|
||||||
|
*
|
||||||
|
* Component Structure:
|
||||||
|
* - CatalogListPanel: Left sidebar with search/filter and item list
|
||||||
|
* - PlanDetailPanel: Main panel showing selected plan/addon details
|
||||||
|
* - PlanEditorWizard: Multi-step wizard for creating/editing plans
|
||||||
|
* - FeaturePicker: Feature selection UI for plans
|
||||||
|
*
|
||||||
|
* To add new feature codes:
|
||||||
|
* 1. Add the feature to featureCatalog.ts in BOOLEAN_FEATURES or INTEGER_FEATURES
|
||||||
|
* 2. Run migrations in backend if needed
|
||||||
|
* 3. Features in the catalog get validation and display benefits
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Feature Catalog
|
||||||
|
export * from './featureCatalog';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { FeaturePicker } from './components/FeaturePicker';
|
||||||
|
export { PlanEditorWizard } from './components/PlanEditorWizard';
|
||||||
|
export { CatalogListPanel } from './components/CatalogListPanel';
|
||||||
|
export { PlanDetailPanel } from './components/PlanDetailPanel';
|
||||||
|
export { AddOnEditorModal } from './components/AddOnEditorModal';
|
||||||
|
export type { CatalogItem } from './components/CatalogListPanel';
|
||||||
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 React from 'react';
|
||||||
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
|
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
|
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
|
||||||
|
|
||||||
@@ -48,11 +49,13 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirmText = 'Confirm',
|
confirmText,
|
||||||
cancelText = 'Cancel',
|
cancelText,
|
||||||
variant = 'info',
|
variant = 'info',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const config = variantConfig[variant];
|
const config = variantConfig[variant];
|
||||||
@@ -95,7 +98,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
disabled={isLoading}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
@@ -120,7 +123,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{confirmText}
|
{confirmText || t('common.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
@@ -27,6 +28,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
tier,
|
tier,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const onboardingMutation = useConnectOnboarding();
|
const onboardingMutation = useConnectOnboarding();
|
||||||
@@ -53,7 +55,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
// Redirect to Stripe onboarding
|
// Redirect to Stripe onboarding
|
||||||
window.location.href = result.url;
|
window.location.href = result.url;
|
||||||
} catch (err: any) {
|
} 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
|
// Redirect to continue onboarding
|
||||||
window.location.href = result.url;
|
window.location.href = result.url;
|
||||||
} catch (err: any) {
|
} 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 = () => {
|
const getAccountTypeLabel = () => {
|
||||||
switch (connectAccount?.account_type) {
|
switch (connectAccount?.account_type) {
|
||||||
case 'standard':
|
case 'standard':
|
||||||
return 'Standard Connect';
|
return t('payments.standardConnect');
|
||||||
case 'express':
|
case 'express':
|
||||||
return 'Express Connect';
|
return t('payments.expressConnect');
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return 'Custom Connect';
|
return t('payments.customConnect');
|
||||||
default:
|
default:
|
||||||
return 'Connect';
|
return t('payments.connect');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,9 +93,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||||
<div className="flex-1">
|
<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">
|
<p className="text-sm text-green-700 mt-1">
|
||||||
Your Stripe account is connected and ready to accept payments.
|
{t('payments.stripeConnectedDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,14 +105,14 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
{/* Account Details */}
|
{/* Account Details */}
|
||||||
{connectAccount && (
|
{connectAccount && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<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="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Status:</span>
|
<span className="text-gray-600">{t('payments.status')}:</span>
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||||
connectAccount.status === 'active'
|
connectAccount.status === 'active'
|
||||||
@@ -126,40 +128,40 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
{connectAccount.charges_enabled ? (
|
{connectAccount.charges_enabled ? (
|
||||||
<>
|
<>
|
||||||
<CreditCard size={14} className="text-green-600" />
|
<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" />
|
<CreditCard size={14} className="text-gray-400" />
|
||||||
<span className="text-gray-500">Disabled</span>
|
<span className="text-gray-500">{t('payments.disabled')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
{connectAccount.payouts_enabled ? (
|
{connectAccount.payouts_enabled ? (
|
||||||
<>
|
<>
|
||||||
<Wallet size={14} className="text-green-600" />
|
<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" />
|
<Wallet size={14} className="text-gray-400" />
|
||||||
<span className="text-gray-500">Disabled</span>
|
<span className="text-gray-500">{t('payments.disabled')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{connectAccount.stripe_account_id && (
|
{connectAccount.stripe_account_id && (
|
||||||
<div className="flex justify-between">
|
<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">
|
<code className="font-mono text-gray-900 text-xs">
|
||||||
{connectAccount.stripe_account_id}
|
{connectAccount.stripe_account_id}
|
||||||
</code>
|
</code>
|
||||||
@@ -175,10 +177,9 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||||
<div className="flex-1">
|
<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">
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
Your Stripe Connect account setup is incomplete.
|
{t('payments.onboardingIncomplete')}
|
||||||
Click below to continue the onboarding process.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshLink}
|
onClick={handleRefreshLink}
|
||||||
@@ -190,7 +191,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
)}
|
)}
|
||||||
Continue Onboarding
|
{t('payments.continueOnboarding')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,24 +202,22 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
{needsOnboarding && (
|
{needsOnboarding && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-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">
|
<p className="text-sm text-blue-700">
|
||||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
{t('payments.tierPaymentDescription', { tier })}
|
||||||
This provides a seamless payment experience for your customers while
|
|
||||||
the platform handles payment processing.
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Secure payment processing
|
{t('payments.securePaymentProcessing')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Automatic payouts to your bank account
|
{t('payments.automaticPayouts')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
PCI compliance handled for you
|
{t('payments.pciCompliance')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +232,7 @@ const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ExternalLink size={18} />
|
<ExternalLink size={18} />
|
||||||
Connect with Stripe
|
{t('payments.connectWithStripe')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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"
|
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||||
>
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
Open Stripe Dashboard
|
{t('payments.openStripeDashboard')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
Building2,
|
Building2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
||||||
|
|
||||||
interface ConnectOnboardingEmbedProps {
|
interface ConnectOnboardingEmbedProps {
|
||||||
@@ -37,6 +38,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
onComplete,
|
onComplete,
|
||||||
onError,
|
onError,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -68,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
colorDanger: '#df1b41',
|
colorDanger: '#df1b41',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
fontSizeBase: '14px',
|
fontSizeBase: '14px',
|
||||||
spacingUnit: '4px',
|
spacingUnit: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -78,12 +80,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
setLoadingState('ready');
|
setLoadingState('ready');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to initialize Stripe Connect:', err);
|
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);
|
setErrorMessage(message);
|
||||||
setLoadingState('error');
|
setLoadingState('error');
|
||||||
onError?.(message);
|
onError?.(message);
|
||||||
}
|
}
|
||||||
}, [loadingState, onError]);
|
}, [loadingState, onError, t]);
|
||||||
|
|
||||||
// Handle onboarding completion
|
// Handle onboarding completion
|
||||||
const handleOnboardingExit = useCallback(async () => {
|
const handleOnboardingExit = useCallback(async () => {
|
||||||
@@ -100,23 +102,23 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
// Handle errors from the Connect component
|
// Handle errors from the Connect component
|
||||||
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
||||||
console.error('Connect component load error:', loadError);
|
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);
|
setErrorMessage(message);
|
||||||
setLoadingState('error');
|
setLoadingState('error');
|
||||||
onError?.(message);
|
onError?.(message);
|
||||||
}, [onError]);
|
}, [onError, t]);
|
||||||
|
|
||||||
// Account type display
|
// Account type display
|
||||||
const getAccountTypeLabel = () => {
|
const getAccountTypeLabel = () => {
|
||||||
switch (connectAccount?.account_type) {
|
switch (connectAccount?.account_type) {
|
||||||
case 'standard':
|
case 'standard':
|
||||||
return 'Standard Connect';
|
return t('payments.standardConnect');
|
||||||
case 'express':
|
case 'express':
|
||||||
return 'Express Connect';
|
return t('payments.expressConnect');
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return 'Custom Connect';
|
return t('payments.customConnect');
|
||||||
default:
|
default:
|
||||||
return 'Connect';
|
return t('payments.connect');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,39 +130,39 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
|
<CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
|
||||||
<div className="flex-1">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
<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="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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">
|
<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}
|
{connectAccount.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<CreditCard size={14} />
|
<CreditCard size={14} />
|
||||||
Enabled
|
{t('payments.enabled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<Wallet size={14} />
|
<Wallet size={14} />
|
||||||
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
|
{connectAccount.payouts_enabled ? t('payments.enabled') : t('payments.pending')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +176,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
return (
|
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">
|
<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} />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -190,7 +192,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
|
<AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
|
||||||
<div className="flex-1">
|
<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>
|
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -216,23 +218,22 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
|
<Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
|
||||||
<div className="flex-1">
|
<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">
|
<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.
|
{t('payments.tierPaymentDescriptionWithOnboarding', { tier })}
|
||||||
Complete the onboarding process to start accepting payments from your customers.
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
|
<ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Secure payment processing
|
{t('payments.securePaymentProcessing')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
Automatic payouts to your bank account
|
{t('payments.automaticPayouts')}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle size={14} />
|
<CheckCircle size={14} />
|
||||||
PCI compliance handled for you
|
{t('payments.pciCompliance')}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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"
|
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} />
|
<CreditCard size={18} />
|
||||||
Start Payment Setup
|
{t('payments.startPaymentSetup')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -255,7 +256,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -265,10 +266,9 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Fill out the information below to finish setting up your payment account.
|
{t('payments.fillOutInfoForPayment')}
|
||||||
Your information is securely handled by Stripe.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
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 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 toast from 'react-hot-toast';
|
||||||
|
import {
|
||||||
|
SCHEDULE_PRESETS,
|
||||||
|
TRIGGER_OPTIONS,
|
||||||
|
OFFSET_PRESETS,
|
||||||
|
getScheduleDescription,
|
||||||
|
getEventTimingDescription,
|
||||||
|
} from '../constants/schedulePresets';
|
||||||
|
import { ErrorMessage } from './ui';
|
||||||
|
|
||||||
interface PluginInstallation {
|
interface PluginInstallation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,11 +22,11 @@ interface PluginInstallation {
|
|||||||
version: string;
|
version: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
logo_url?: string;
|
logo_url?: string;
|
||||||
template_variables: Record<string, any>;
|
template_variables: Record<string, unknown>;
|
||||||
scheduled_task?: number;
|
scheduled_task?: number;
|
||||||
scheduled_task_name?: string;
|
scheduled_task_name?: string;
|
||||||
installed_at: string;
|
installed_at: string;
|
||||||
config_values: Record<string, any>;
|
config_values: Record<string, unknown>;
|
||||||
has_update: boolean;
|
has_update: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,65 +36,6 @@ interface CreateTaskModalProps {
|
|||||||
onSuccess: () => void;
|
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
|
// Task type: scheduled or event-based
|
||||||
type TaskType = 'scheduled' | 'event';
|
type TaskType = 'scheduled' | 'event';
|
||||||
|
|
||||||
@@ -154,41 +103,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
|||||||
setStep(2);
|
setStep(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScheduleDescription = () => {
|
// Use shared helper functions from constants
|
||||||
if (scheduleMode === 'onetime') {
|
const scheduleDescriptionText = getScheduleDescription(
|
||||||
if (runAtDate && runAtTime) {
|
scheduleMode,
|
||||||
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
|
selectedPreset,
|
||||||
}
|
runAtDate,
|
||||||
return 'Select date and time';
|
runAtTime,
|
||||||
}
|
customCron
|
||||||
if (scheduleMode === 'advanced') {
|
);
|
||||||
return `Custom: ${customCron}`;
|
|
||||||
}
|
|
||||||
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
|
|
||||||
return preset?.description || 'Select a schedule';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEventTimingDescription = () => {
|
const eventTimingDescriptionText = getEventTimingDescription(selectedTrigger, selectedOffset);
|
||||||
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 showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
|
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
<span className="text-sm text-green-800 dark:text-green-200">
|
<span className="text-sm text-green-800 dark:text-green-200">
|
||||||
<strong>Schedule:</strong> {getScheduleDescription()}
|
<strong>Schedule:</strong> {scheduleDescriptionText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -657,7 +581,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||||
<span className="text-sm text-purple-800 dark:text-purple-200">
|
<span className="text-sm text-purple-800 dark:text-purple-200">
|
||||||
<strong>Runs:</strong> {getEventTimingDescription()}
|
<strong>Runs:</strong> {eventTimingDescriptionText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -665,11 +589,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && <ErrorMessage message={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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
|||||||
colorText: '#1e293b',
|
colorText: '#1e293b',
|
||||||
colorDanger: '#dc2626',
|
colorDanger: '#dc2626',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
spacingUnit: '4px',
|
spacingUnit: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,77 +5,89 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
||||||
|
|
||||||
export interface TestUser {
|
export interface TestUser {
|
||||||
username: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: string;
|
role: string;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
category: 'platform' | 'business' | 'customer';
|
||||||
}
|
}
|
||||||
|
|
||||||
const testUsers: TestUser[] = [
|
const testUsers: TestUser[] = [
|
||||||
{
|
{
|
||||||
username: 'superuser',
|
email: 'superuser@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'SUPERUSER',
|
role: 'SUPERUSER',
|
||||||
label: 'Platform Superuser',
|
label: 'Platform Superuser',
|
||||||
color: 'bg-purple-600 hover:bg-purple-700',
|
color: 'bg-purple-600 hover:bg-purple-700',
|
||||||
|
category: 'platform',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_manager',
|
email: 'manager@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_MANAGER',
|
role: 'PLATFORM_MANAGER',
|
||||||
label: 'Platform Manager',
|
label: 'Platform Manager',
|
||||||
color: 'bg-blue-600 hover:bg-blue-700',
|
color: 'bg-blue-600 hover:bg-blue-700',
|
||||||
|
category: 'platform',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_sales',
|
email: 'sales@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_SALES',
|
role: 'PLATFORM_SALES',
|
||||||
label: 'Platform Sales',
|
label: 'Platform Sales',
|
||||||
color: 'bg-green-600 hover:bg-green-700',
|
color: 'bg-green-600 hover:bg-green-700',
|
||||||
|
category: 'platform',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_support',
|
email: 'support@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_SUPPORT',
|
role: 'PLATFORM_SUPPORT',
|
||||||
label: 'Platform Support',
|
label: 'Platform Support',
|
||||||
color: 'bg-yellow-600 hover:bg-yellow-700',
|
color: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
|
category: 'platform',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_owner',
|
email: 'owner@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_OWNER',
|
role: 'TENANT_OWNER',
|
||||||
label: 'Business Owner',
|
label: 'Business Owner',
|
||||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||||
|
category: 'business',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_manager',
|
email: 'manager@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_MANAGER',
|
||||||
label: 'Business Manager',
|
label: 'Business Manager',
|
||||||
color: 'bg-pink-600 hover:bg-pink-700',
|
color: 'bg-pink-600 hover:bg-pink-700',
|
||||||
|
category: 'business',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_staff',
|
email: 'staff@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_STAFF',
|
role: 'TENANT_STAFF',
|
||||||
label: 'Staff Member',
|
label: 'Staff Member',
|
||||||
color: 'bg-teal-600 hover:bg-teal-700',
|
color: 'bg-teal-600 hover:bg-teal-700',
|
||||||
|
category: 'business',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'customer',
|
email: 'customer@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'CUSTOMER',
|
role: 'CUSTOMER',
|
||||||
label: 'Customer',
|
label: 'Customer',
|
||||||
color: 'bg-orange-600 hover:bg-orange-700',
|
color: 'bg-orange-600 hover:bg-orange-700',
|
||||||
|
category: 'customer',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type UserFilter = 'all' | 'platform' | 'business';
|
||||||
|
|
||||||
interface DevQuickLoginProps {
|
interface DevQuickLoginProps {
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
filter?: UserFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
export function DevQuickLogin({ embedded = false, filter = 'all' }: DevQuickLoginProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
@@ -85,17 +97,25 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter users based on the filter prop
|
||||||
|
const filteredUsers = testUsers.filter((user) => {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'platform') return user.category === 'platform';
|
||||||
|
if (filter === 'business') return user.category === 'business';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const handleQuickLogin = async (user: TestUser) => {
|
const handleQuickLogin = async (user: TestUser) => {
|
||||||
setLoading(user.username);
|
setLoading(user.email);
|
||||||
try {
|
try {
|
||||||
// Call token auth API
|
// Call custom login API that supports email login
|
||||||
const response = await apiClient.post('/auth-token/', {
|
const response = await apiClient.post('/auth/login/', {
|
||||||
username: user.username,
|
email: user.email,
|
||||||
password: user.password,
|
password: user.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
// 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
|
// Clear any existing masquerade stack - this is a fresh login
|
||||||
localStorage.removeItem('masquerade_stack');
|
localStorage.removeItem('masquerade_stack');
|
||||||
@@ -174,14 +194,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{testUsers.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<button
|
<button
|
||||||
key={user.username}
|
key={user.email}
|
||||||
onClick={() => handleQuickLogin(user)}
|
onClick={() => handleQuickLogin(user)}
|
||||||
disabled={loading !== null}
|
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`}
|
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">
|
<span className="flex items-center justify-center">
|
||||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -26,6 +27,7 @@ interface DomainPurchaseProps {
|
|||||||
type Step = 'search' | 'details' | 'confirm';
|
type Step = 'search' | 'details' | 'confirm';
|
||||||
|
|
||||||
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState<Step>('search');
|
const [step, setStep] = useState<Step>('search');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
|
const [searchResults, setSearchResults] = useState<DomainAvailability[]>([]);
|
||||||
@@ -138,7 +140,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
>
|
>
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">Search</span>
|
<span className="text-sm font-medium">{t('common.search')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||||
<div
|
<div
|
||||||
@@ -155,7 +157,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
>
|
>
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">Details</span>
|
<span className="text-sm font-medium">{t('settings.domain.details')}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||||
<div
|
<div
|
||||||
@@ -172,7 +174,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
>
|
>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">Confirm</span>
|
<span className="text-sm font-medium">{t('common.confirm')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +188,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
@@ -200,14 +202,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
) : (
|
) : (
|
||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
Search
|
{t('common.search')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Search Results */}
|
{/* Search Results */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="space-y-2">
|
||||||
{searchResults.map((result) => (
|
{searchResults.map((result) => (
|
||||||
<div
|
<div
|
||||||
@@ -230,7 +232,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</span>
|
</span>
|
||||||
{result.premium && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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"
|
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" />
|
<ShoppingCart className="h-4 w-4" />
|
||||||
Select
|
{t('settings.domain.select')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!result.available && (
|
{!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>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +266,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
{registeredDomains && registeredDomains.length > 0 && (
|
{registeredDomains && registeredDomains.length > 0 && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-700">
|
<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">
|
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||||
Your Registered Domains
|
{t('settings.domain.yourRegisteredDomains')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{registeredDomains.map((domain) => (
|
{registeredDomains.map((domain) => (
|
||||||
@@ -289,7 +291,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
{domain.expires_at && (
|
{domain.expires_at && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,7 +318,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
onClick={() => setStep('search')}
|
onClick={() => setStep('search')}
|
||||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
>
|
>
|
||||||
Change
|
{t('settings.domain.change')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Registration Period
|
{t('payments.registrationPeriod')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={years}
|
value={years}
|
||||||
@@ -334,7 +336,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
>
|
>
|
||||||
{[1, 2, 3, 5, 10].map((y) => (
|
{[1, 2, 3, 5, 10].map((y) => (
|
||||||
<option key={y} value={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)}
|
{((selectedDomain.premium_price || selectedDomain.price || 0) * y).toFixed(2)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -355,10 +357,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
<Shield className="h-5 w-5 text-gray-400" />
|
<Shield className="h-5 w-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-900 dark:text-white font-medium">
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
WHOIS Privacy Protection
|
{t('settings.domain.whoisPrivacy')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Hide your personal information from public WHOIS lookups
|
{t('settings.domain.whoisPrivacyDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,9 +376,9 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className="h-5 w-5 text-gray-400" />
|
<RefreshCw className="h-5 w-5 text-gray-400" />
|
||||||
<div>
|
<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">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Automatically renew this domain before it expires
|
{t('settings.domain.autoRenewalDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,10 +395,10 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
<Globe className="h-5 w-5 text-gray-400" />
|
<Globe className="h-5 w-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-900 dark:text-white font-medium">
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
Auto-configure as Custom Domain
|
{t('settings.domain.autoConfigure')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Automatically set up this domain for your business
|
{t('settings.domain.autoConfigureDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,12 +408,12 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
<div className="pt-6 border-t border-gray-100 dark:border-gray-700">
|
<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">
|
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||||
Registrant Information
|
{t('settings.domain.registrantInfo')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
First Name *
|
{t('settings.domain.firstName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -423,7 +425,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Last Name *
|
{t('settings.domain.lastName')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -435,7 +437,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Email *
|
{t('customers.email')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -447,7 +449,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Phone *
|
{t('customers.phone')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -460,7 +462,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Address *
|
{t('customers.address')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -472,7 +474,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
City *
|
{t('customers.city')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -484,7 +486,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
State/Province *
|
{t('settings.domain.stateProvince')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -496,7 +498,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
ZIP/Postal Code *
|
{t('settings.domain.zipPostalCode')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -508,19 +510,19 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Country *
|
{t('settings.domain.country')} *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={contact.country}
|
value={contact.country}
|
||||||
onChange={(e) => updateContact('country', e.target.value)}
|
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"
|
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="US">{t('settings.domain.countries.us')}</option>
|
||||||
<option value="CA">Canada</option>
|
<option value="CA">{t('settings.domain.countries.ca')}</option>
|
||||||
<option value="GB">United Kingdom</option>
|
<option value="GB">{t('settings.domain.countries.gb')}</option>
|
||||||
<option value="AU">Australia</option>
|
<option value="AU">{t('settings.domain.countries.au')}</option>
|
||||||
<option value="DE">Germany</option>
|
<option value="DE">{t('settings.domain.countries.de')}</option>
|
||||||
<option value="FR">France</option>
|
<option value="FR">{t('settings.domain.countries.fr')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,14 +534,14 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
onClick={() => setStep('search')}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep('confirm')}
|
onClick={() => setStep('confirm')}
|
||||||
disabled={!isContactValid()}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -548,36 +550,36 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
{/* Step 3: Confirm */}
|
{/* Step 3: Confirm */}
|
||||||
{step === 'confirm' && selectedDomain && (
|
{step === 'confirm' && selectedDomain && (
|
||||||
<div className="space-y-6">
|
<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="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 space-y-3">
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{selectedDomain.domain}
|
{selectedDomain.domain}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{whoisPrivacy ? 'Enabled' : 'Disabled'}
|
{whoisPrivacy ? t('platform.settings.enabled') : t('platform.settings.none')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{autoRenew ? 'Enabled' : 'Disabled'}
|
{autoRenew ? t('platform.settings.enabled') : t('platform.settings.none')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="font-bold text-xl text-brand-600 dark:text-brand-400">
|
||||||
${getPrice().toFixed(2)}
|
${getPrice().toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -587,7 +589,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
|
|
||||||
{/* Registrant Summary */}
|
{/* Registrant Summary */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
<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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{contact.first_name} {contact.last_name}
|
{contact.first_name} {contact.last_name}
|
||||||
<br />
|
<br />
|
||||||
@@ -602,7 +604,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
{registerMutation.isError && (
|
{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">
|
<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" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
<span>Registration failed. Please try again.</span>
|
<span>{t('payments.registrationFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -612,7 +614,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
onClick={() => setStep('details')}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handlePurchase}
|
onClick={handlePurchase}
|
||||||
@@ -624,7 +626,7 @@ const DomainPurchase: React.FC<DomainPurchaseProps> = ({ onSuccess }) => {
|
|||||||
) : (
|
) : (
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
Complete Purchase
|
{t('settings.domain.completePurchase')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from '../api/client';
|
import axios from '../api/client';
|
||||||
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
||||||
|
import { formatLocalDate } from '../utils/dateUtils';
|
||||||
|
|
||||||
interface ScheduledTask {
|
interface ScheduledTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
|
|||||||
setScheduleMode('onetime');
|
setScheduleMode('onetime');
|
||||||
if (task.run_at) {
|
if (task.run_at) {
|
||||||
const date = new Date(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));
|
setRunAtTime(date.toTimeString().slice(0, 5));
|
||||||
}
|
}
|
||||||
} else if (task.schedule_type === 'INTERVAL') {
|
} 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;
|
||||||
@@ -20,13 +20,17 @@ const routeToHelpPath: Record<string, string> = {
|
|||||||
'/services': '/help/services',
|
'/services': '/help/services',
|
||||||
'/resources': '/help/resources',
|
'/resources': '/help/resources',
|
||||||
'/staff': '/help/staff',
|
'/staff': '/help/staff',
|
||||||
|
'/time-blocks': '/help/time-blocks',
|
||||||
|
'/my-availability': '/help/time-blocks',
|
||||||
'/messages': '/help/messages',
|
'/messages': '/help/messages',
|
||||||
'/tickets': '/help/ticketing',
|
'/tickets': '/help/ticketing',
|
||||||
'/payments': '/help/payments',
|
'/payments': '/help/payments',
|
||||||
|
'/contracts': '/help/contracts',
|
||||||
|
'/contracts/templates': '/help/contracts',
|
||||||
'/plugins': '/help/plugins',
|
'/plugins': '/help/plugins',
|
||||||
'/plugins/marketplace': '/help/plugins',
|
'/plugins/marketplace': '/help/plugins',
|
||||||
'/plugins/my-plugins': '/help/plugins',
|
'/plugins/my-plugins': '/help/plugins',
|
||||||
'/plugins/create': '/help/plugins/create',
|
'/plugins/create': '/help/plugins/docs',
|
||||||
'/settings': '/help/settings/general',
|
'/settings': '/help/settings/general',
|
||||||
'/settings/general': '/help/settings/general',
|
'/settings/general': '/help/settings/general',
|
||||||
'/settings/resource-types': '/help/settings/resource-types',
|
'/settings/resource-types': '/help/settings/resource-types',
|
||||||
|
|||||||
134
frontend/src/components/LocationSelector.tsx
Normal file
134
frontend/src/components/LocationSelector.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* LocationSelector Component
|
||||||
|
*
|
||||||
|
* A reusable dropdown for selecting a business location.
|
||||||
|
* Hidden when only one location exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocations } from '../hooks/useLocations';
|
||||||
|
import { FormSelect, SelectOption } from './ui/FormSelect';
|
||||||
|
import { Location } from '../types';
|
||||||
|
|
||||||
|
interface LocationSelectorProps {
|
||||||
|
/** Currently selected location ID */
|
||||||
|
value?: number | null;
|
||||||
|
/** Callback when location is selected */
|
||||||
|
onChange: (locationId: number | null) => void;
|
||||||
|
/** Label for the selector */
|
||||||
|
label?: string;
|
||||||
|
/** Error message */
|
||||||
|
error?: string;
|
||||||
|
/** Hint text */
|
||||||
|
hint?: string;
|
||||||
|
/** Placeholder text */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether the field is required */
|
||||||
|
required?: boolean;
|
||||||
|
/** Whether to include inactive locations */
|
||||||
|
includeInactive?: boolean;
|
||||||
|
/** Whether the selector is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Force show even with single location (for admin purposes) */
|
||||||
|
forceShow?: boolean;
|
||||||
|
/** Container class name */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocationSelector - Dropdown for selecting a business location
|
||||||
|
*
|
||||||
|
* Automatically hides when:
|
||||||
|
* - Only one active location exists (unless forceShow is true)
|
||||||
|
* - Locations are still loading
|
||||||
|
*
|
||||||
|
* The component auto-selects the only location when there's just one.
|
||||||
|
*/
|
||||||
|
export const LocationSelector: React.FC<LocationSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label = 'Location',
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
placeholder = 'Select a location',
|
||||||
|
required = false,
|
||||||
|
includeInactive = false,
|
||||||
|
disabled = false,
|
||||||
|
forceShow = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const { data: locations, isLoading, isError } = useLocations({ includeInactive });
|
||||||
|
|
||||||
|
// Don't render if loading or error
|
||||||
|
if (isLoading || isError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only active locations if not including inactive
|
||||||
|
const availableLocations = locations ?? [];
|
||||||
|
|
||||||
|
// Hide if only one location (unless forceShow)
|
||||||
|
if (availableLocations.length <= 1 && !forceShow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build options from locations
|
||||||
|
const options: SelectOption<string>[] = availableLocations.map((loc: Location) => ({
|
||||||
|
value: String(loc.id),
|
||||||
|
label: loc.is_primary
|
||||||
|
? `${loc.name} (Primary)`
|
||||||
|
: loc.is_active
|
||||||
|
? loc.name
|
||||||
|
: `${loc.name} (Inactive)`,
|
||||||
|
disabled: !loc.is_active && !includeInactive,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedValue = e.target.value;
|
||||||
|
onChange(selectedValue ? Number(selectedValue) : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSelect
|
||||||
|
label={label}
|
||||||
|
value={value ? String(value) : ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={options}
|
||||||
|
error={error}
|
||||||
|
hint={hint}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
containerClassName={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine if location selector should be shown
|
||||||
|
*/
|
||||||
|
export const useShouldShowLocationSelector = (includeInactive = false): boolean => {
|
||||||
|
const { data: locations, isLoading } = useLocations({ includeInactive });
|
||||||
|
|
||||||
|
if (isLoading) return false;
|
||||||
|
return (locations?.length ?? 0) > 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to auto-select location when only one exists
|
||||||
|
*/
|
||||||
|
export const useAutoSelectLocation = (
|
||||||
|
currentValue: number | null | undefined,
|
||||||
|
onChange: (locationId: number | null) => void
|
||||||
|
) => {
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Auto-select if only one location and no value selected
|
||||||
|
if (locations?.length === 1 && !currentValue) {
|
||||||
|
onChange(locations[0].id);
|
||||||
|
}
|
||||||
|
}, [locations, currentValue, onChange]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationSelector;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Eye, XCircle } from 'lucide-react';
|
import { Eye, XCircle } from 'lucide-react';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
|
|
||||||
@@ -11,8 +12,9 @@ interface MasqueradeBannerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
|
const MasqueradeBanner: React.FC<MasqueradeBannerProps> = ({ effectiveUser, originalUser, previousUser, onStop }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const buttonText = previousUser ? `Return to ${previousUser.name}` : 'Stop Masquerading';
|
const buttonText = previousUser ? t('platform.masquerade.returnTo', { name: previousUser.name }) : t('platform.masquerade.stopMasquerading');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-orange-600 text-white px-4 py-2 shadow-md flex items-center justify-between z-50 relative">
|
<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} />
|
<Eye size={18} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Masquerading as <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
|
{t('platform.masquerade.masqueradingAs')} <strong>{effectiveUser.name}</strong> ({effectiveUser.role})
|
||||||
<span className="opacity-75 mx-2 text-xs">|</span>
|
<span className="opacity-75 mx-2 text-xs">|</span>
|
||||||
Logged in as {originalUser.name}
|
{t('platform.masquerade.loggedInAs', { name: originalUser.name })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 {
|
import {
|
||||||
useNotifications,
|
useNotifications,
|
||||||
useUnreadNotificationCount,
|
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('/dashboard/time-blocks');
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to target if available
|
// Navigate to target if available
|
||||||
if (notification.target_url) {
|
if (notification.target_url) {
|
||||||
navigate(notification.target_url);
|
navigate(notification.target_url);
|
||||||
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
clearAllMutation.mutate();
|
clearAllMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationIcon = (targetType: string | null) => {
|
const getNotificationIcon = (notification: Notification) => {
|
||||||
switch (targetType) {
|
// 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':
|
case 'ticket':
|
||||||
return <Ticket size={16} className="text-blue-500" />;
|
return <Ticket size={16} className="text-blue-500" />;
|
||||||
case 'event':
|
case 'event':
|
||||||
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{getNotificationIcon(notification.target_type)}
|
{getNotificationIcon(notification)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
|
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
|
||||||
@@ -211,7 +224,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/notifications');
|
navigate('/dashboard/notifications');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings/billing')}
|
onClick={() => navigate('/dashboard/settings/billing')}
|
||||||
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowUpRight size={16} />
|
<ArrowUpRight size={16} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
|
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||||
|
|
||||||
@@ -75,6 +75,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
|||||||
<Shield size={18} className="shrink-0" />
|
<Shield size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
{!isCollapsed && <span>{t('nav.staff')}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
|
||||||
|
<CreditCard size={18} className="shrink-0" />
|
||||||
|
{!isCollapsed && <span>Billing</span>}
|
||||||
|
</Link>
|
||||||
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
|
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
|
||||||
<Settings size={18} className="shrink-0" />
|
<Settings size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}
|
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismi
|
|||||||
{t('quota.modal.dismissButton', 'Remind Me Later')}
|
{t('quota.modal.dismissButton', 'Remind Me Later')}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/settings/quota"
|
to="/dashboard/settings/quota"
|
||||||
onClick={handleDismiss}
|
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"
|
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDis
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to="/settings/quota"
|
to="/dashboard/settings/quota"
|
||||||
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
isCritical || isUrgent
|
isCritical || isUrgent
|
||||||
? 'bg-white/20 hover:bg-white/30 text-white'
|
? 'bg-white/20 hover:bg-white/30 text-white'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
|
||||||
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
|
import { format, addDays, addWeeks, addMonths, startOfDay, startOfWeek, startOfMonth, endOfDay, endOfWeek, endOfMonth, eachDayOfInterval, eachHourOfInterval, isToday, isSameDay, getDay } from 'date-fns';
|
||||||
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
|
import { useAppointments, useUpdateAppointment } from '../hooks/useAppointments';
|
||||||
@@ -28,6 +29,7 @@ interface ResourceCalendarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
|
const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourceName, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
const [viewMode, setViewMode] = useState<ViewMode>('day');
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -712,12 +714,12 @@ const ResourceCalendar: React.FC<ResourceCalendarProps> = ({ resourceId, resourc
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && appointments.length === 0 && (
|
{!isLoading && appointments.length === 0 && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 { clsx } from 'clsx';
|
||||||
import { Clock, DollarSign } from 'lucide-react';
|
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 {
|
export interface DraggableEventProps {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import { Clock, GripVertical } from 'lucide-react';
|
import { Clock, GripVertical } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface PendingAppointment {
|
export interface PendingAppointment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,6 +16,7 @@ interface PendingItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: `pending-${appointment.id}`,
|
id: `pending-${appointment.id}`,
|
||||||
data: {
|
data: {
|
||||||
@@ -43,7 +45,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
|
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
<span>{appointment.durationMinutes} min</span>
|
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -54,16 +56,18 @@ interface PendingSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
|
const PendingSidebar: React.FC<PendingSidebarProps> = ({ appointments }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full shrink-0">
|
<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">
|
<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">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 overflow-y-auto flex-1">
|
<div className="p-4 overflow-y-auto flex-1">
|
||||||
{appointments.length === 0 ? (
|
{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 => (
|
appointments.map(apt => (
|
||||||
<PendingItem key={apt.id} appointment={apt} />
|
<PendingItem key={apt.id} appointment={apt} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import { Clock, GripVertical, Trash2 } from 'lucide-react';
|
import { Clock, GripVertical, Trash2 } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export interface PendingAppointment {
|
export interface PendingAppointment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,6 +23,7 @@ interface PendingItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id: `pending-${appointment.id}`,
|
id: `pending-${appointment.id}`,
|
||||||
data: {
|
data: {
|
||||||
@@ -50,7 +52,7 @@ const PendingItem: React.FC<PendingItemProps> = ({ appointment }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
<span>{appointment.durationMinutes} min</span>
|
<span>{appointment.durationMinutes} {t('scheduler.min')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -63,11 +65,13 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments, scrollRef }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
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 }}>
|
<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 */}
|
{/* 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 }}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Resources List (Synced Scroll) */}
|
{/* Resources List (Synced Scroll) */}
|
||||||
@@ -89,10 +93,10 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resourceName}</p>
|
<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">
|
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
|
||||||
Resource
|
{t('scheduler.resource')}
|
||||||
{layout.laneCount > 1 && (
|
{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]">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -106,11 +110,11 @@ const Sidebar: React.FC<SidebarProps> = ({ resourceLayouts, pendingAppointments,
|
|||||||
{/* Pending Requests (Fixed Bottom) */}
|
{/* 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">
|
<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">
|
<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>
|
</h3>
|
||||||
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
|
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
|
||||||
{pendingAppointments.length === 0 ? (
|
{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 => (
|
pendingAppointments.map(apt => (
|
||||||
<PendingItem key={apt.id} appointment={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="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">
|
<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} />
|
<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>
|
</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 React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import './ServiceList.css';
|
import './ServiceList.css';
|
||||||
|
|
||||||
const ServiceList = ({ services, onSelectService, loading }) => {
|
const ServiceList = ({ services, onSelectService, loading }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (loading) {
|
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) {
|
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 (
|
return (
|
||||||
<div className="service-list">
|
<div className="service-list">
|
||||||
<h2>Available Services</h2>
|
<h2>{t('services.availableServices')}</h2>
|
||||||
<div className="service-grid">
|
<div className="service-grid">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<div
|
<div
|
||||||
@@ -28,7 +30,7 @@ const ServiceList = ({ services, onSelectService, loading }) => {
|
|||||||
{service.description && (
|
{service.description && (
|
||||||
<p className="service-description">{service.description}</p>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Plug,
|
Plug,
|
||||||
|
FileSignature,
|
||||||
|
CalendarOff,
|
||||||
|
LayoutTemplate,
|
||||||
|
MapPin,
|
||||||
|
Image,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Business, User } from '../types';
|
import { Business, User } from '../types';
|
||||||
import { useLogout } from '../hooks/useAuth';
|
import { useLogout } from '../hooks/useAuth';
|
||||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||||
|
import UnfinishedBadge from './ui/UnfinishedBadge';
|
||||||
import {
|
import {
|
||||||
SidebarSection,
|
SidebarSection,
|
||||||
SidebarItem,
|
SidebarItem,
|
||||||
@@ -40,9 +46,11 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
const { canUse } = usePlanFeatures();
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
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 canViewSettings = role === 'owner';
|
||||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||||
|
const canSendMessages = user.can_send_messages === true;
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
logoutMutation.mutate();
|
logoutMutation.mutate();
|
||||||
@@ -59,7 +67,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
<button
|
<button
|
||||||
onClick={toggleCollapse}
|
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`}
|
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 ? (
|
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
|
||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
@@ -102,65 +110,125 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
{/* Core Features - Always visible */}
|
{/* Core Features - Always visible */}
|
||||||
<SidebarSection isCollapsed={isCollapsed}>
|
<SidebarSection isCollapsed={isCollapsed}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/"
|
to="/dashboard"
|
||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
label={t('nav.dashboard')}
|
label={t('nav.dashboard')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
|
{!isStaff && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/scheduler"
|
to="/dashboard/scheduler"
|
||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
label={t('nav.scheduler')}
|
label={t('nav.scheduler')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{!isStaff && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/tasks"
|
to="/dashboard/tasks"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label={t('nav.tasks', 'Tasks')}
|
label={t('nav.tasks', 'Tasks')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
locked={!canUse('plugins') || !canUse('tasks')}
|
locked={!canUse('plugins') || !canUse('tasks')}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{isStaff && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/my-schedule"
|
||||||
|
icon={CalendarDays}
|
||||||
|
label={t('nav.mySchedule', 'My Schedule')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(role === 'staff' || role === 'resource') && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/my-availability"
|
||||||
|
icon={CalendarOff}
|
||||||
|
label={t('nav.myAvailability', 'My Availability')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
{/* Manage Section - Staff+ */}
|
{/* Manage Section - Staff+ */}
|
||||||
{canViewManagementPages && (
|
{canViewManagementPages && (
|
||||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/customers"
|
to="/dashboard/site-editor"
|
||||||
icon={Users}
|
icon={LayoutTemplate}
|
||||||
label={t('nav.customers')}
|
label={t('nav.siteBuilder', 'Site Builder')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/gallery"
|
||||||
|
icon={Image}
|
||||||
|
label={t('nav.gallery', 'Media Gallery')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/services"
|
to="/dashboard/customers"
|
||||||
|
icon={Users}
|
||||||
|
label={t('nav.customers')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/services"
|
||||||
icon={Briefcase}
|
icon={Briefcase}
|
||||||
label={t('nav.services', 'Services')}
|
label={t('nav.services', 'Services')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/resources"
|
to="/dashboard/resources"
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
label={t('nav.resources')}
|
label={t('nav.resources')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
{canViewAdminPages && (
|
{canViewAdminPages && (
|
||||||
|
<>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/staff"
|
to="/dashboard/staff"
|
||||||
icon={Users}
|
icon={Users}
|
||||||
label={t('nav.staff')}
|
label={t('nav.staff')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
/>
|
/>
|
||||||
|
{canUse('contracts') && (
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/contracts"
|
||||||
|
icon={FileSignature}
|
||||||
|
label={t('nav.contracts', 'Contracts')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/time-blocks"
|
||||||
|
icon={CalendarOff}
|
||||||
|
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
to="/dashboard/locations"
|
||||||
|
icon={MapPin}
|
||||||
|
label={t('nav.locations', 'Locations')}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
locked={!canUse('multi_location')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Communicate Section - Tickets + Messages */}
|
{/* Communicate Section - Tickets + Messages */}
|
||||||
{(canViewTickets || canViewAdminPages) && (
|
{(canViewTickets || canSendMessages) && (
|
||||||
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
|
||||||
{canViewAdminPages && (
|
{canSendMessages && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/messages"
|
to="/dashboard/messages"
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
label={t('nav.messages')}
|
label={t('nav.messages')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -168,7 +236,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
)}
|
)}
|
||||||
{canViewTickets && (
|
{canViewTickets && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/tickets"
|
to="/dashboard/tickets"
|
||||||
icon={Ticket}
|
icon={Ticket}
|
||||||
label={t('nav.tickets')}
|
label={t('nav.tickets')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -181,7 +249,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
{canViewAdminPages && (
|
{canViewAdminPages && (
|
||||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/payments"
|
to="/dashboard/payments"
|
||||||
icon={CreditCard}
|
icon={CreditCard}
|
||||||
label={t('nav.payments')}
|
label={t('nav.payments')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -194,11 +262,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
{canViewAdminPages && (
|
{canViewAdminPages && (
|
||||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/plugins/my-plugins"
|
to="/dashboard/plugins/my-plugins"
|
||||||
icon={Plug}
|
icon={Plug}
|
||||||
label={t('nav.plugins', 'Plugins')}
|
label={t('nav.plugins', 'Plugins')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
locked={!canUse('plugins')}
|
locked={!canUse('plugins')}
|
||||||
|
badgeElement={<UnfinishedBadge />}
|
||||||
/>
|
/>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
)}
|
||||||
@@ -209,14 +278,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
<SidebarSection isCollapsed={isCollapsed}>
|
<SidebarSection isCollapsed={isCollapsed}>
|
||||||
{canViewSettings && (
|
{canViewSettings && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/settings"
|
to="/dashboard/settings"
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
label={t('nav.businessSettings')}
|
label={t('nav.businessSettings')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/help"
|
to="/dashboard/help"
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label={t('nav.helpDocs', 'Help & Docs')}
|
label={t('nav.helpDocs', 'Help & Docs')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -234,7 +303,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
>
|
>
|
||||||
<SmoothScheduleLogo className="w-5 h-5 text-white" />
|
<SmoothScheduleLogo className="w-5 h-5 text-white" />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="text-white/60">Smooth Schedule</span>
|
<span className="text-white/60">{t('nav.smoothSchedule')}</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
roles: ['manager'],
|
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
|
// Staff-only permissions
|
||||||
{
|
{
|
||||||
key: 'can_view_all_schedules',
|
key: 'can_view_all_schedules',
|
||||||
@@ -87,6 +96,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
roles: ['staff'],
|
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)
|
// Shared permissions (both manager and staff)
|
||||||
{
|
{
|
||||||
key: 'can_access_tickets',
|
key: 'can_access_tickets',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -30,6 +31,7 @@ interface StripeApiKeysFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
|
const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSuccess }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [secretKey, setSecretKey] = useState('');
|
const [secretKey, setSecretKey] = useState('');
|
||||||
const [publishableKey, setPublishableKey] = useState('');
|
const [publishableKey, setPublishableKey] = useState('');
|
||||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||||
@@ -72,7 +74,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setValidationResult({
|
setValidationResult({
|
||||||
valid: false,
|
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) {
|
} catch (error: any) {
|
||||||
setValidationResult({
|
setValidationResult({
|
||||||
valid: false,
|
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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<CheckCircle size={18} className="text-green-500" />
|
<CheckCircle size={18} className="text-green-500" />
|
||||||
Stripe Keys Configured
|
{t('payments.stripeApiKeys.configured')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Environment Badge */}
|
{/* Environment Badge */}
|
||||||
@@ -136,12 +138,12 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{keyEnvironment === 'test' ? (
|
{keyEnvironment === 'test' ? (
|
||||||
<>
|
<>
|
||||||
<FlaskConical size={12} />
|
<FlaskConical size={12} />
|
||||||
Test Mode
|
{t('payments.stripeApiKeys.testMode')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap size={12} />
|
<Zap size={12} />
|
||||||
Live Mode
|
{t('payments.stripeApiKeys.liveMode')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -163,22 +165,22 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.publishable_key_masked}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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>
|
<code className="font-mono text-gray-900 dark:text-white">{apiKeys.secret_key_masked}</code>
|
||||||
</div>
|
</div>
|
||||||
{apiKeys.stripe_account_name && (
|
{apiKeys.stripe_account_name && (
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="text-gray-900 dark:text-white">{apiKeys.stripe_account_name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{apiKeys.last_validated_at && (
|
{apiKeys.last_validated_at && (
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="text-gray-900 dark:text-white">
|
||||||
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
|
{new Date(apiKeys.last_validated_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
@@ -190,10 +192,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{keyEnvironment === 'test' && apiKeys.status === 'active' && (
|
{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">
|
<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" />
|
<FlaskConical size={16} className="shrink-0 mt-0.5" />
|
||||||
<span>
|
<span dangerouslySetInnerHTML={{ __html: t('payments.stripeApiKeys.testKeysWarning') }} />
|
||||||
You are using <strong>test keys</strong>. Payments will not be processed for real.
|
|
||||||
Switch to live keys when ready to accept real payments.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -214,14 +213,14 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
)}
|
)}
|
||||||
Re-validate
|
{t('payments.stripeApiKeys.revalidate')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
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"
|
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} />
|
<Trash2 size={16} />
|
||||||
Remove
|
{t('payments.stripeApiKeys.remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,10 +232,9 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||||
<div>
|
<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">
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
Your API keys have been deprecated because you upgraded to a paid tier.
|
{t('payments.stripeApiKeys.deprecatedMessage')}
|
||||||
Please complete Stripe Connect onboarding to accept payments.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,19 +245,18 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{(!isConfigured || isDeprecated) && (
|
{(!isConfigured || isDeprecated) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">
|
<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>
|
</h4>
|
||||||
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Enter your Stripe API keys to enable payment collection.
|
{t('payments.stripeApiKeys.enterKeysDescription')}{' '}
|
||||||
You can find these in your{' '}
|
|
||||||
<a
|
<a
|
||||||
href="https://dashboard.stripe.com/apikeys"
|
href="https://dashboard.stripe.com/apikeys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
Stripe Dashboard
|
{t('payments.stripeApiKeys.stripeDashboard')}
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
@@ -267,7 +264,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{/* Publishable Key */}
|
{/* Publishable Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Publishable Key
|
{t('payments.stripeApiKeys.publishableKeyLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Key
|
<Key
|
||||||
@@ -290,7 +287,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{/* Secret Key */}
|
{/* Secret Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Secret Key
|
{t('payments.stripeApiKeys.secretKeyLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Key
|
<Key
|
||||||
@@ -335,7 +332,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
{validationResult.valid ? (
|
{validationResult.valid ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<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 && (
|
{validationResult.environment && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${
|
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' ? (
|
{validationResult.environment === 'test' ? (
|
||||||
<>
|
<>
|
||||||
<FlaskConical size={10} />
|
<FlaskConical size={10} />
|
||||||
Test Mode
|
{t('payments.stripeApiKeys.testMode')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Zap size={10} />
|
<Zap size={10} />
|
||||||
Live Mode
|
{t('payments.stripeApiKeys.liveMode')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{validationResult.accountName && (
|
{validationResult.accountName && (
|
||||||
<div>Connected to: {validationResult.accountName}</div>
|
<div>{t('payments.stripeApiKeys.connectedTo', { accountName: validationResult.accountName })}</div>
|
||||||
)}
|
)}
|
||||||
{validationResult.environment === 'test' && (
|
{validationResult.environment === 'test' && (
|
||||||
<div className="text-amber-700 dark:text-amber-400 text-xs mt-1">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +383,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
)}
|
)}
|
||||||
Validate
|
{t('payments.stripeApiKeys.validate')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -398,7 +395,7 @@ const StripeApiKeysForm: React.FC<StripeApiKeysFormProps> = ({ apiKeys, onSucces
|
|||||||
) : (
|
) : (
|
||||||
<Key size={16} />
|
<Key size={16} />
|
||||||
)}
|
)}
|
||||||
Save Keys
|
{t('payments.stripeApiKeys.saveKeys')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
Remove API Keys?
|
{t('payments.stripeApiKeys.removeApiKeys')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Are you sure you want to remove your Stripe API keys?
|
{t('payments.stripeApiKeys.removeApiKeysMessage')}
|
||||||
You will not be able to accept payments until you add them again.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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"
|
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" />}
|
{deleteMutation.isPending && <Loader2 size={16} className="animate-spin" />}
|
||||||
Remove
|
{t('payments.stripeApiKeys.remove')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -37,6 +38,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
transactionId,
|
transactionId,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
|
const { data: transaction, isLoading, error } = useTransactionDetail(transactionId);
|
||||||
const refundMutation = useRefundTransaction();
|
const refundMutation = useRefundTransaction();
|
||||||
|
|
||||||
@@ -62,11 +64,11 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
if (refundType === 'partial') {
|
if (refundType === 'partial') {
|
||||||
const amountCents = Math.round(parseFloat(refundAmount) * 100);
|
const amountCents = Math.round(parseFloat(refundAmount) * 100);
|
||||||
if (isNaN(amountCents) || amountCents <= 0) {
|
if (isNaN(amountCents) || amountCents <= 0) {
|
||||||
setRefundError('Please enter a valid refund amount');
|
setRefundError(t('payments.enterValidRefundAmount'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (amountCents > transaction.refundable_amount) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
request.amount = amountCents;
|
request.amount = amountCents;
|
||||||
@@ -80,7 +82,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
setShowRefundForm(false);
|
setShowRefundForm(false);
|
||||||
setRefundAmount('');
|
setRefundAmount('');
|
||||||
} catch (err: any) {
|
} 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>
|
</p>
|
||||||
{pm.exp_month && pm.exp_year && (
|
{pm.exp_month && pm.exp_year && (
|
||||||
<p className="text-sm text-gray-500">
|
<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})`}
|
{pm.funding && ` (${pm.funding})`}
|
||||||
</p>
|
</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 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>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Transaction Details
|
{t('payments.transactionDetails')}
|
||||||
</h3>
|
</h3>
|
||||||
{transaction && (
|
{transaction && (
|
||||||
<p className="text-sm text-gray-500 font-mono">
|
<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="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 text-red-700">
|
<div className="flex items-center gap-2 text-red-700">
|
||||||
<AlertCircle size={18} />
|
<AlertCircle size={18} />
|
||||||
<p className="font-medium">Failed to load transaction details</p>
|
<p className="font-medium">{t('payments.failedToLoadTransaction')}</p>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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} />
|
<RefreshCcw size={16} />
|
||||||
Issue Refund
|
{t('payments.issueRefund')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="bg-red-50 border border-red-200 rounded-lg p-4 space-y-4">
|
||||||
<div className="flex items-center gap-2 text-red-800">
|
<div className="flex items-center gap-2 text-red-800">
|
||||||
<RefreshCcw size={18} />
|
<RefreshCcw size={18} />
|
||||||
<h4 className="font-semibold">Issue Refund</h4>
|
<h4 className="font-semibold">{t('payments.issueRefund')}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Refund Type */}
|
{/* Refund Type */}
|
||||||
@@ -252,7 +254,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
className="text-red-600 focus:ring-red-500"
|
className="text-red-600 focus:ring-red-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-700">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -263,7 +265,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
onChange={() => setRefundType('partial')}
|
onChange={() => setRefundType('partial')}
|
||||||
className="text-red-600 focus:ring-red-500"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
{refundType === 'partial' && (
|
{refundType === 'partial' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
<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 */}
|
{/* Reason */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Refund Reason
|
{t('payments.refundReason')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={refundReason}
|
value={refundReason}
|
||||||
onChange={(e) => setRefundReason(e.target.value as RefundRequest['reason'])}
|
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"
|
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="requested_by_customer">{t('payments.requestedByCustomer')}</option>
|
||||||
<option value="duplicate">Duplicate charge</option>
|
<option value="duplicate">{t('payments.duplicate')}</option>
|
||||||
<option value="fraudulent">Fraudulent</option>
|
<option value="fraudulent">{t('payments.fraudulent')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -322,12 +324,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
{refundMutation.isPending ? (
|
{refundMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="animate-spin" size={16} />
|
<Loader2 className="animate-spin" size={16} />
|
||||||
Processing...
|
{t('payments.processing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RefreshCcw size={16} />
|
<RefreshCcw size={16} />
|
||||||
Confirm Refund
|
{t('payments.processRefund')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -340,7 +342,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
disabled={refundMutation.isPending}
|
disabled={refundMutation.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,7 +354,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
Customer
|
{t('payments.customer')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
||||||
{transaction.customer_name && (
|
{transaction.customer_name && (
|
||||||
@@ -378,27 +380,27 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<DollarSign size={16} />
|
<DollarSign size={16} />
|
||||||
Amount Breakdown
|
{t('payments.amountBreakdown')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<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>
|
<span className="font-medium">{transaction.amount_display}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<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>
|
<span className="text-red-600">-{transaction.fee_display}</span>
|
||||||
</div>
|
</div>
|
||||||
{transaction.total_refunded > 0 && (
|
{transaction.total_refunded > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<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">
|
<span className="text-orange-600">
|
||||||
-${(transaction.total_refunded / 100).toFixed(2)}
|
-${(transaction.total_refunded / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
|
<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">
|
<span className="font-bold text-green-600">
|
||||||
${(transaction.net_amount / 100).toFixed(2)}
|
${(transaction.net_amount / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -412,7 +414,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<CreditCard size={16} />
|
<CreditCard size={16} />
|
||||||
Payment Method
|
{t('payments.paymentMethod')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
{getPaymentMethodDisplay()}
|
{getPaymentMethodDisplay()}
|
||||||
@@ -425,7 +427,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<Receipt size={16} />
|
<Receipt size={16} />
|
||||||
Description
|
{t('payments.description')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
<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>
|
<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">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<RefreshCcw size={16} />
|
<RefreshCcw size={16} />
|
||||||
Refund History
|
{t('payments.refundHistory')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{transaction.refunds.map((refund: RefundInfo) => (
|
{transaction.refunds.map((refund: RefundInfo) => (
|
||||||
@@ -451,7 +453,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<p className="text-sm text-orange-600">
|
<p className="text-sm text-orange-600">
|
||||||
{refund.reason
|
{refund.reason
|
||||||
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
? refund.reason.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
: 'No reason provided'}
|
: t('payments.noReasonProvided')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-orange-500 mt-1">
|
<p className="text-xs text-orange-500 mt-1">
|
||||||
{formatRefundDate(refund.created)}
|
{formatRefundDate(refund.created)}
|
||||||
@@ -482,12 +484,12 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
Timeline
|
{t('payments.timeline')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-3">
|
<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="flex items-center gap-3 text-sm">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
<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">
|
<span className="ml-auto text-gray-900 dark:text-white">
|
||||||
{formatDate(transaction.created_at)}
|
{formatDate(transaction.created_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -495,7 +497,7 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
{transaction.updated_at !== transaction.created_at && (
|
{transaction.updated_at !== transaction.created_at && (
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
<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">
|
<span className="ml-auto text-gray-900 dark:text-white">
|
||||||
{formatDate(transaction.updated_at)}
|
{formatDate(transaction.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -508,29 +510,29 @@ const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<ArrowLeftRight size={16} />
|
<ArrowLeftRight size={16} />
|
||||||
Technical Details
|
{t('payments.technicalDetails')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-2 font-mono text-xs">
|
<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">
|
<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">
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
{transaction.stripe_payment_intent_id}
|
{transaction.stripe_payment_intent_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{transaction.stripe_charge_id && (
|
{transaction.stripe_charge_id && (
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
{transaction.stripe_charge_id}
|
{transaction.stripe_charge_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="text-gray-700 dark:text-gray-300">{transaction.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<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">
|
<span className="text-gray-700 dark:text-gray-300 uppercase">
|
||||||
{transaction.currency}
|
{transaction.currency}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
|
|||||||
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
|
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
|
||||||
|
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
navigate('/upgrade');
|
navigate('/dashboard/upgrade');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const BannerPrompt: React.FC<{ feature: FeatureKey; showDescription: boolean }>
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/settings/billing"
|
to="/dashboard/settings/billing"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<Crown className="w-4 h-4" />
|
<Crown className="w-4 h-4" />
|
||||||
@@ -97,7 +97,7 @@ const OverlayPrompt: React.FC<{
|
|||||||
{FEATURE_DESCRIPTIONS[feature]}
|
{FEATURE_DESCRIPTIONS[feature]}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/settings/billing"
|
to="/dashboard/settings/billing"
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<Crown className="w-5 h-5" />
|
<Crown className="w-5 h-5" />
|
||||||
|
|||||||
166
frontend/src/components/__tests__/ApiTokensSection.test.tsx
Normal file
166
frontend/src/components/__tests__/ApiTokensSection.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import ApiTokensSection from '../ApiTokensSection';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the hooks
|
||||||
|
const mockTokens = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Token',
|
||||||
|
key_prefix: 'abc123',
|
||||||
|
scopes: ['read:appointments', 'write:appointments'],
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
last_used_at: '2024-01-02T00:00:00Z',
|
||||||
|
expires_at: null,
|
||||||
|
created_by: { full_name: 'John Doe', username: 'john' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Revoked Token',
|
||||||
|
key_prefix: 'xyz789',
|
||||||
|
scopes: ['read:resources'],
|
||||||
|
is_active: false,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
last_used_at: null,
|
||||||
|
expires_at: null,
|
||||||
|
created_by: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockUseApiTokens = vi.fn();
|
||||||
|
const mockUseCreateApiToken = vi.fn();
|
||||||
|
const mockUseRevokeApiToken = vi.fn();
|
||||||
|
const mockUseUpdateApiToken = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useApiTokens', () => ({
|
||||||
|
useApiTokens: () => mockUseApiTokens(),
|
||||||
|
useCreateApiToken: () => mockUseCreateApiToken(),
|
||||||
|
useRevokeApiToken: () => mockUseRevokeApiToken(),
|
||||||
|
useUpdateApiToken: () => mockUseUpdateApiToken(),
|
||||||
|
API_SCOPES: [
|
||||||
|
{ value: 'read:appointments', label: 'Read Appointments', description: 'View appointments' },
|
||||||
|
{ value: 'write:appointments', label: 'Write Appointments', description: 'Create/edit appointments' },
|
||||||
|
{ value: 'read:resources', label: 'Read Resources', description: 'View resources' },
|
||||||
|
],
|
||||||
|
SCOPE_PRESETS: {
|
||||||
|
read_only: { label: 'Read Only', description: 'View data only', scopes: ['read:appointments', 'read:resources'] },
|
||||||
|
read_write: { label: 'Read & Write', description: 'Full access', scopes: ['read:appointments', 'write:appointments', 'read:resources'] },
|
||||||
|
custom: { label: 'Custom', description: 'Select individual permissions', scopes: [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ApiTokensSection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseCreateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||||
|
mockUseRevokeApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||||
|
mockUseUpdateApiToken.mockReturnValue({ mutateAsync: vi.fn(), isPending: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: true, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error state', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: undefined, isLoading: false, error: new Error('Failed') });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/Failed to load API tokens/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no tokens', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('No API tokens yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tokens list', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('Test Token')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Revoked Token')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders section title', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('API Tokens')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders New Token button', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('New Token')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders API Docs link', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('API Docs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens new token modal when button clicked', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByText('New Token'));
|
||||||
|
// Modal title should appear
|
||||||
|
expect(screen.getByRole('heading', { name: 'Create API Token' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows active tokens count', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/Active Tokens \(1\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows revoked tokens count', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/Revoked Tokens \(1\)/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows token key prefix', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/abc123••••••••/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows revoked badge for inactive tokens', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: mockTokens, isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('Revoked')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description text', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText(/Create and manage API tokens/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders create button in empty state', () => {
|
||||||
|
mockUseApiTokens.mockReturnValue({ data: [], isLoading: false, error: null });
|
||||||
|
render(<ApiTokensSection />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('Create API Token')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
114
frontend/src/components/__tests__/ConfirmationModal.test.tsx
Normal file
114
frontend/src/components/__tests__/ConfirmationModal.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ConfirmationModal from '../ConfirmationModal';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ConfirmationModal', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
title: 'Test Title',
|
||||||
|
message: 'Test message',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not open', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ConfirmationModal {...defaultProps} isOpen={false} />
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title when open', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders message when open', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Test message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders message as ReactNode', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmationModal
|
||||||
|
{...defaultProps}
|
||||||
|
message={<span data-testid="custom-message">Custom content</span>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when close button is clicked', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} />);
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
fireEvent.click(buttons[0]);
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button is clicked', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('common.cancel'));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onConfirm when confirm button is clicked', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText('common.confirm'));
|
||||||
|
expect(defaultProps.onConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom confirm text', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
|
||||||
|
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom cancel text', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
|
||||||
|
expect(screen.getByText('No, keep')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders info variant', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} variant="info" />);
|
||||||
|
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning variant', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} variant="warning" />);
|
||||||
|
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders danger variant', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} variant="danger" />);
|
||||||
|
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders success variant', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} variant="success" />);
|
||||||
|
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables buttons when loading', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spinner when loading', () => {
|
||||||
|
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
|
||||||
|
const spinner = document.querySelector('.animate-spin');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import EmailTemplateSelector from '../EmailTemplateSelector';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock API client
|
||||||
|
vi.mock('../../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EmailTemplateSelector', () => {
|
||||||
|
it('renders select element', () => {
|
||||||
|
render(
|
||||||
|
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows placeholder text after loading', async () => {
|
||||||
|
render(
|
||||||
|
<EmailTemplateSelector
|
||||||
|
value={undefined}
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Select a template"
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
// Wait for loading to finish and placeholder to appear
|
||||||
|
await screen.findByText('Select a template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled prop is true', () => {
|
||||||
|
render(
|
||||||
|
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EmailTemplateSelector
|
||||||
|
value={undefined}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="custom-class"
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state message when no templates', async () => {
|
||||||
|
render(
|
||||||
|
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
// Wait for loading to finish
|
||||||
|
await screen.findByText('No email templates yet.');
|
||||||
|
});
|
||||||
|
});
|
||||||
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FeatureGate component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { FeatureGate, LimitGate } from '../FeatureGate';
|
||||||
|
import * as useEntitlementsModule from '../../hooks/useEntitlements';
|
||||||
|
|
||||||
|
// Mock the useEntitlements hook
|
||||||
|
vi.mock('../../hooks/useEntitlements', () => ({
|
||||||
|
useEntitlements: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FeatureGate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when feature is enabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: true },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when feature is disabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: false },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback when feature is disabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: false },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
feature="can_use_sms_reminders"
|
||||||
|
fallback={<div>Upgrade to access SMS</div>}
|
||||||
|
>
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while loading', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading component when provided and loading', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
feature="can_use_sms_reminders"
|
||||||
|
loadingFallback={<div>Loading...</div>}
|
||||||
|
>
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks multiple features with requireAll=true', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||||
|
requireAll={true}
|
||||||
|
>
|
||||||
|
<div>Multi Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not render because mobile_app is disabled
|
||||||
|
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks multiple features with requireAll=false (any)', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||||
|
requireAll={false}
|
||||||
|
>
|
||||||
|
<div>Multi Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render because at least one (sms) is enabled
|
||||||
|
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LimitGate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when under limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={5}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when at limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={10}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when over limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={15}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback when over limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate
|
||||||
|
limit="max_users"
|
||||||
|
currentUsage={15}
|
||||||
|
fallback={<div>Upgrade for more users</div>}
|
||||||
|
>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when limit is null (unlimited)', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={1000}>
|
||||||
|
<div>Unlimited Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When limit is null, treat as unlimited
|
||||||
|
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import FloatingHelpButton from '../FloatingHelpButton';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FloatingHelpButton', () => {
|
||||||
|
const renderWithRouter = (initialPath: string) => {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<FloatingHelpButton />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders help link on dashboard', () => {
|
||||||
|
renderWithRouter('/dashboard');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to correct help page for dashboard', () => {
|
||||||
|
renderWithRouter('/dashboard');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to correct help page for scheduler', () => {
|
||||||
|
renderWithRouter('/scheduler');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/scheduler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to correct help page for services', () => {
|
||||||
|
renderWithRouter('/services');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/services');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to correct help page for resources', () => {
|
||||||
|
renderWithRouter('/resources');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to correct help page for settings', () => {
|
||||||
|
renderWithRouter('/settings/general');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/settings/general');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null on help pages', () => {
|
||||||
|
const { container } = renderWithRouter('/help/dashboard');
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-label', () => {
|
||||||
|
renderWithRouter('/dashboard');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('aria-label', 'Help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has title attribute', () => {
|
||||||
|
renderWithRouter('/dashboard');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('title', 'Help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to default help for unknown routes', () => {
|
||||||
|
renderWithRouter('/unknown-route');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles dynamic routes by matching prefix', () => {
|
||||||
|
renderWithRouter('/customers/123');
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/customers');
|
||||||
|
});
|
||||||
|
});
|
||||||
57
frontend/src/components/__tests__/HelpButton.test.tsx
Normal file
57
frontend/src/components/__tests__/HelpButton.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import HelpButton from '../HelpButton';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('HelpButton', () => {
|
||||||
|
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
|
||||||
|
return render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<HelpButton {...props} />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders help link', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct href', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/dashboard' });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('href', '/help/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders help text', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/test' });
|
||||||
|
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has title attribute', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/test' });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveAttribute('title', 'Help');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default styles', () => {
|
||||||
|
renderHelpButton({ helpPath: '/help/test' });
|
||||||
|
const link = screen.getByRole('link');
|
||||||
|
expect(link).toHaveClass('inline-flex');
|
||||||
|
expect(link).toHaveClass('items-center');
|
||||||
|
});
|
||||||
|
});
|
||||||
93
frontend/src/components/__tests__/LanguageSelector.test.tsx
Normal file
93
frontend/src/components/__tests__/LanguageSelector.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import LanguageSelector from '../LanguageSelector';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
i18n: {
|
||||||
|
language: 'en',
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock i18n module
|
||||||
|
vi.mock('../../i18n', () => ({
|
||||||
|
supportedLanguages: [
|
||||||
|
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||||
|
{ code: 'es', name: 'Español', flag: '🇪🇸' },
|
||||||
|
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LanguageSelector', () => {
|
||||||
|
describe('dropdown variant', () => {
|
||||||
|
it('renders dropdown button', () => {
|
||||||
|
render(<LanguageSelector />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current language flag by default', () => {
|
||||||
|
render(<LanguageSelector />);
|
||||||
|
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current language name on larger screens', () => {
|
||||||
|
render(<LanguageSelector />);
|
||||||
|
expect(screen.getByText('English')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens dropdown on click', () => {
|
||||||
|
render(<LanguageSelector />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all languages when open', () => {
|
||||||
|
render(<LanguageSelector />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(screen.getByText('Español')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Français')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides flag when showFlag is false', () => {
|
||||||
|
render(<LanguageSelector showFlag={false} />);
|
||||||
|
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(<LanguageSelector className="custom-class" />);
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inline variant', () => {
|
||||||
|
it('renders all language buttons', () => {
|
||||||
|
render(<LanguageSelector variant="inline" />);
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders language names', () => {
|
||||||
|
render(<LanguageSelector variant="inline" />);
|
||||||
|
expect(screen.getByText(/English/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Español/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Français/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights current language', () => {
|
||||||
|
render(<LanguageSelector variant="inline" />);
|
||||||
|
const englishButton = screen.getByText(/English/).closest('button');
|
||||||
|
expect(englishButton).toHaveClass('bg-brand-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows flags by default', () => {
|
||||||
|
render(<LanguageSelector variant="inline" />);
|
||||||
|
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
201
frontend/src/components/__tests__/LocationSelector.test.tsx
Normal file
201
frontend/src/components/__tests__/LocationSelector.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import React from 'react';
|
||||||
|
import { LocationSelector, useShouldShowLocationSelector } from '../LocationSelector';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Mock the useLocations hook
|
||||||
|
vi.mock('../../hooks/useLocations', () => ({
|
||||||
|
useLocations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useLocations } from '../../hooks/useLocations';
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LocationSelector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when loading', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<LocationSelector value={null} onChange={onChange} />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when there is only one location', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<LocationSelector value={null} onChange={onChange} />,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders selector when multiple locations exist', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||||
|
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LocationSelector value={null} onChange={onChange} />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Location')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Main Office (Primary)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Branch Office')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows single location when forceShow is true', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [{ id: 1, name: 'Main Office', is_active: true, is_primary: true }],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LocationSelector value={null} onChange={onChange} forceShow />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Location')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when selection changes', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||||
|
{ id: 2, name: 'Branch Office', is_active: true, is_primary: false },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LocationSelector value={null} onChange={onChange} />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const select = screen.getByLabelText('Location');
|
||||||
|
fireEvent.change(select, { target: { value: '2' } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks inactive locations appropriately', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Main Office', is_active: true, is_primary: true },
|
||||||
|
{ id: 2, name: 'Old Branch', is_active: false, is_primary: false },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LocationSelector value={null} onChange={onChange} includeInactive />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Old Branch (Inactive)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays custom label', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Location A', is_active: true, is_primary: false },
|
||||||
|
{ id: 2, name: 'Location B', is_active: true, is_primary: false },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LocationSelector value={null} onChange={onChange} label="Select Store" />, {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Select Store')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useShouldShowLocationSelector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when loading', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when only one location', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [{ id: 1, name: 'Main', is_active: true }],
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when multiple locations exist', () => {
|
||||||
|
vi.mocked(useLocations).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Main', is_active: true },
|
||||||
|
{ id: 2, name: 'Branch', is_active: true },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useShouldShowLocationSelector(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
frontend/src/components/__tests__/MasqueradeBanner.test.tsx
Normal file
68
frontend/src/components/__tests__/MasqueradeBanner.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import MasqueradeBanner from '../MasqueradeBanner';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: { name?: string }) => {
|
||||||
|
if (options?.name) return `${key} ${options.name}`;
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('MasqueradeBanner', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
|
||||||
|
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
|
||||||
|
previousUser: null,
|
||||||
|
onStop: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders effective user name', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders effective user role', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
// The role is split across elements: "(" + "staff" + ")"
|
||||||
|
expect(screen.getByText(/staff/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders original user info', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onStop when button is clicked', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
const stopButton = screen.getByRole('button');
|
||||||
|
fireEvent.click(stopButton);
|
||||||
|
expect(defaultProps.onStop).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows return to previous user text when previousUser exists', () => {
|
||||||
|
const propsWithPrevious = {
|
||||||
|
...defaultProps,
|
||||||
|
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
|
||||||
|
};
|
||||||
|
render(<MasqueradeBanner {...propsWithPrevious} />);
|
||||||
|
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stop masquerading text when no previousUser', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with masquerading label', () => {
|
||||||
|
render(<MasqueradeBanner {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
463
frontend/src/components/__tests__/NotificationDropdown.test.tsx
Normal file
463
frontend/src/components/__tests__/NotificationDropdown.test.tsx
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import NotificationDropdown from '../NotificationDropdown';
|
||||||
|
import { Notification } from '../../api/notifications';
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-router-dom navigate
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
const mockNotifications: Notification[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
verb: 'created',
|
||||||
|
read: false,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data: {},
|
||||||
|
actor_type: 'user',
|
||||||
|
actor_display: 'John Doe',
|
||||||
|
target_type: 'appointment',
|
||||||
|
target_display: 'Appointment with Jane',
|
||||||
|
target_url: '/appointments/1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
verb: 'updated',
|
||||||
|
read: true,
|
||||||
|
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
||||||
|
data: {},
|
||||||
|
actor_type: 'user',
|
||||||
|
actor_display: 'Jane Smith',
|
||||||
|
target_type: 'event',
|
||||||
|
target_display: 'Meeting scheduled',
|
||||||
|
target_url: '/events/2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
verb: 'created a ticket',
|
||||||
|
read: false,
|
||||||
|
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
|
||||||
|
data: { ticket_id: '123' },
|
||||||
|
actor_type: 'user',
|
||||||
|
actor_display: 'Support Team',
|
||||||
|
target_type: 'ticket',
|
||||||
|
target_display: 'Ticket #123',
|
||||||
|
target_url: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
verb: 'requested time off',
|
||||||
|
read: false,
|
||||||
|
timestamp: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days ago
|
||||||
|
data: { type: 'time_off_request' },
|
||||||
|
actor_type: 'user',
|
||||||
|
actor_display: 'Bob Johnson',
|
||||||
|
target_type: null,
|
||||||
|
target_display: 'Time off request',
|
||||||
|
target_url: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useNotifications', () => ({
|
||||||
|
useNotifications: vi.fn(),
|
||||||
|
useUnreadNotificationCount: vi.fn(),
|
||||||
|
useMarkNotificationRead: vi.fn(),
|
||||||
|
useMarkAllNotificationsRead: vi.fn(),
|
||||||
|
useClearAllNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
useNotifications,
|
||||||
|
useUnreadNotificationCount,
|
||||||
|
useMarkNotificationRead,
|
||||||
|
useMarkAllNotificationsRead,
|
||||||
|
useClearAllNotifications,
|
||||||
|
} from '../../hooks/useNotifications';
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NotificationDropdown', () => {
|
||||||
|
const mockMarkRead = vi.fn();
|
||||||
|
const mockMarkAllRead = vi.fn();
|
||||||
|
const mockClearAll = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mock implementations
|
||||||
|
vi.mocked(useNotifications).mockReturnValue({
|
||||||
|
data: mockNotifications,
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||||
|
data: 2,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useMarkNotificationRead).mockReturnValue({
|
||||||
|
mutate: mockMarkRead,
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
|
||||||
|
mutate: mockMarkAllRead,
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useClearAllNotifications).mockReturnValue({
|
||||||
|
mutate: mockClearAll,
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders bell icon button', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByRole('button', { name: /open notifications/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays unread count badge when there are unread notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display badge when unread count is 0', () => {
|
||||||
|
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||||
|
data: 0,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays "99+" when unread count exceeds 99', () => {
|
||||||
|
vi.mocked(useUnreadNotificationCount).mockReturnValue({
|
||||||
|
data: 150,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render dropdown when closed', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dropdown interactions', () => {
|
||||||
|
it('opens dropdown when bell icon is clicked', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown when close button is clicked', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const closeButtons = screen.getAllByRole('button');
|
||||||
|
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
|
||||||
|
fireEvent.click(closeButton!);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dropdown when clicking outside', async () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Simulate clicking outside
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Notifications')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification list', () => {
|
||||||
|
it('displays all notifications when dropdown is open', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays loading state', () => {
|
||||||
|
vi.mocked(useNotifications).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('common.loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays empty state when no notifications', () => {
|
||||||
|
vi.mocked(useNotifications).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('No notifications yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights unread notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const notificationButtons = screen.getAllByRole('button');
|
||||||
|
const unreadNotification = notificationButtons.find(btn =>
|
||||||
|
btn.textContent?.includes('John Doe')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unreadNotification).toHaveClass('bg-blue-50/50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification actions', () => {
|
||||||
|
it('marks notification as read when clicked', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const notification = screen.getByText('John Doe').closest('button');
|
||||||
|
fireEvent.click(notification!);
|
||||||
|
|
||||||
|
expect(mockMarkRead).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to target URL when notification is clicked', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const notification = screen.getByText('John Doe').closest('button');
|
||||||
|
fireEvent.click(notification!);
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/appointments/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTicketClick for ticket notifications', () => {
|
||||||
|
const mockOnTicketClick = vi.fn();
|
||||||
|
render(<NotificationDropdown onTicketClick={mockOnTicketClick} />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const ticketNotification = screen.getByText('Support Team').closest('button');
|
||||||
|
fireEvent.click(ticketNotification!);
|
||||||
|
|
||||||
|
expect(mockOnTicketClick).toHaveBeenCalledWith('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to time-blocks for time off requests', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
||||||
|
fireEvent.click(timeOffNotification!);
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/time-blocks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all notifications as read', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
// Find the mark all read button (CheckCheck icon)
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const markAllReadButton = buttons.find(btn =>
|
||||||
|
btn.getAttribute('title')?.includes('Mark all as read')
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(markAllReadButton!);
|
||||||
|
expect(mockMarkAllRead).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all read notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const clearButton = screen.getByText('Clear read');
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(mockClearAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to notifications page when "View all" is clicked', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const viewAllButton = screen.getByText('View all');
|
||||||
|
fireEvent.click(viewAllButton);
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/notifications');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification icons', () => {
|
||||||
|
it('displays Clock icon for time off requests', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const timeOffNotification = screen.getByText('Bob Johnson').closest('button');
|
||||||
|
const icon = timeOffNotification?.querySelector('svg');
|
||||||
|
|
||||||
|
expect(icon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Ticket icon for ticket notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const ticketNotification = screen.getByText('Support Team').closest('button');
|
||||||
|
const icon = ticketNotification?.querySelector('svg');
|
||||||
|
|
||||||
|
expect(icon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays Calendar icon for event notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const eventNotification = screen.getByText('Jane Smith').closest('button');
|
||||||
|
const icon = eventNotification?.querySelector('svg');
|
||||||
|
|
||||||
|
expect(icon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Timestamp formatting', () => {
|
||||||
|
it('displays "Just now" for recent notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
// The first notification is just now
|
||||||
|
expect(screen.getByText('Just now')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays relative time for older notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
// Check if notification timestamps are rendered
|
||||||
|
// We have 4 notifications in our mock data, each should have a timestamp
|
||||||
|
const notificationButtons = screen.getAllByRole('button').filter(btn =>
|
||||||
|
btn.textContent?.includes('John Doe') ||
|
||||||
|
btn.textContent?.includes('Jane Smith') ||
|
||||||
|
btn.textContent?.includes('Support Team') ||
|
||||||
|
btn.textContent?.includes('Bob Johnson')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notificationButtons.length).toBeGreaterThan(0);
|
||||||
|
// At least one notification should have a timestamp
|
||||||
|
const hasTimestamp = notificationButtons.some(btn => btn.textContent?.match(/Just now|\d+[hmd] ago|\d{1,2}\/\d{1,2}\/\d{4}/));
|
||||||
|
expect(hasTimestamp).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Variants', () => {
|
||||||
|
it('renders with light variant', () => {
|
||||||
|
render(<NotificationDropdown variant="light" />, { wrapper: createWrapper() });
|
||||||
|
const button = screen.getByRole('button', { name: /open notifications/i });
|
||||||
|
expect(button).toHaveClass('text-white/80');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with dark variant (default)', () => {
|
||||||
|
render(<NotificationDropdown variant="dark" />, { wrapper: createWrapper() });
|
||||||
|
const button = screen.getByRole('button', { name: /open notifications/i });
|
||||||
|
expect(button).toHaveClass('text-gray-400');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading states', () => {
|
||||||
|
it('disables mark all read button when mutation is pending', () => {
|
||||||
|
vi.mocked(useMarkAllNotificationsRead).mockReturnValue({
|
||||||
|
mutate: mockMarkAllRead,
|
||||||
|
isPending: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const markAllReadButton = buttons.find(btn =>
|
||||||
|
btn.getAttribute('title')?.includes('Mark all as read')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markAllReadButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables clear all button when mutation is pending', () => {
|
||||||
|
vi.mocked(useClearAllNotifications).mockReturnValue({
|
||||||
|
mutate: mockClearAll,
|
||||||
|
isPending: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
const clearButton = screen.getByText('Clear read');
|
||||||
|
expect(clearButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Footer visibility', () => {
|
||||||
|
it('shows footer when there are notifications', () => {
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear read')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('View all')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides footer when there are no notifications', () => {
|
||||||
|
vi.mocked(useNotifications).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<NotificationDropdown />, { wrapper: createWrapper() });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /open notifications/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Clear read')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('View all')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
577
frontend/src/components/__tests__/OAuthButtons.test.tsx
Normal file
577
frontend/src/components/__tests__/OAuthButtons.test.tsx
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for OAuthButtons component
|
||||||
|
*
|
||||||
|
* Tests OAuth provider buttons for social login.
|
||||||
|
* Covers:
|
||||||
|
* - Rendering providers from API
|
||||||
|
* - Button clicks and OAuth initiation
|
||||||
|
* - Loading states (initial load and button clicks)
|
||||||
|
* - Provider-specific styling (colors, icons)
|
||||||
|
* - Disabled state
|
||||||
|
* - Error handling
|
||||||
|
* - Empty state (no providers)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import OAuthButtons from '../OAuthButtons';
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
const mockUseOAuthProviders = vi.fn();
|
||||||
|
const mockUseInitiateOAuth = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useOAuth', () => ({
|
||||||
|
useOAuthProviders: () => mockUseOAuthProviders(),
|
||||||
|
useInitiateOAuth: () => mockUseInitiateOAuth(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to wrap component with QueryClient
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OAuthButtons', () => {
|
||||||
|
const mockMutate = vi.fn();
|
||||||
|
const mockOnSuccess = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: false,
|
||||||
|
variables: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading State', () => {
|
||||||
|
it('should show loading spinner while fetching providers', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Look for the spinner SVG element with animate-spin class
|
||||||
|
const spinner = container.querySelector('.animate-spin');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show providers while loading', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /continue with/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty State', () => {
|
||||||
|
it('should render nothing when no providers are available', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render nothing when providers data is null', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Provider Rendering', () => {
|
||||||
|
it('should render Google provider button', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple provider buttons', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
{ name: 'apple', display_name: 'Apple' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /continue with facebook/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /continue with apple/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply Google-specific styling (white bg, border)', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toHaveClass('bg-white', 'text-gray-900', 'border-gray-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply Apple-specific styling (black bg)', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'apple', display_name: 'Apple' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with apple/i });
|
||||||
|
expect(button).toHaveClass('bg-black', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply Facebook-specific styling (blue bg)', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'facebook', display_name: 'Facebook' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with facebook/i });
|
||||||
|
expect(button).toHaveClass('bg-[#1877F2]', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply LinkedIn-specific styling', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'linkedin', display_name: 'LinkedIn' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with linkedin/i });
|
||||||
|
expect(button).toHaveClass('bg-[#0A66C2]', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render unknown provider with fallback styling', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'custom_provider', display_name: 'Custom Provider' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with custom provider/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('bg-gray-600', 'text-white');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display provider icons', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Icons should be present (rendered as text in config)
|
||||||
|
expect(screen.getByText('G')).toBeInTheDocument(); // Google icon
|
||||||
|
expect(screen.getByText('f')).toBeInTheDocument(); // Facebook icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Button Clicks', () => {
|
||||||
|
it('should call OAuth initiation when button is clicked', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSuccess callback after successful OAuth initiation', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockMutate.mockImplementation((provider, { onSuccess }) => {
|
||||||
|
onSuccess?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons onSuccess={mockOnSuccess} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple provider clicks', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const googleButton = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
|
||||||
|
|
||||||
|
fireEvent.click(googleButton);
|
||||||
|
expect(mockMutate).toHaveBeenCalledWith('google', expect.any(Object));
|
||||||
|
|
||||||
|
fireEvent.click(facebookButton);
|
||||||
|
expect(mockMutate).toHaveBeenCalledWith('facebook', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not initiate OAuth when button is disabled', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(mockMutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not initiate OAuth when another button is pending', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /connecting/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Should not call mutate again
|
||||||
|
expect(mockMutate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading State During OAuth', () => {
|
||||||
|
it('should show loading state on clicked button', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/continue with google/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show spinner icon during loading', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Loader2 icon should be rendered
|
||||||
|
const spinner = container.querySelector('.animate-spin');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only show loading on the clicked button', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
// Google button should show loading
|
||||||
|
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Facebook button should still show normal text
|
||||||
|
expect(screen.getByText(/continue with facebook/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disabled State', () => {
|
||||||
|
it('should disable all buttons when disabled prop is true', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const googleButton = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
const facebookButton = screen.getByRole('button', { name: /continue with facebook/i });
|
||||||
|
|
||||||
|
expect(googleButton).toBeDisabled();
|
||||||
|
expect(facebookButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply disabled styling when disabled', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons disabled={true} />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable all buttons during OAuth pending', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should log error on OAuth initiation failure', () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new Error('OAuth error');
|
||||||
|
mockMutate.mockImplementation((provider, { onError }) => {
|
||||||
|
onError?.(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('OAuth initiation error:', error);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Provider Variants', () => {
|
||||||
|
it('should render Microsoft provider', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'microsoft', display_name: 'Microsoft' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with microsoft/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('bg-[#00A4EF]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render X (Twitter) provider', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'x', display_name: 'X' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with x/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('bg-black');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Twitch provider', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'twitch', display_name: 'Twitch' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with twitch/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('bg-[#9146FF]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Button Styling', () => {
|
||||||
|
it('should have consistent button styling', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toHaveClass(
|
||||||
|
'w-full',
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'rounded-lg',
|
||||||
|
'shadow-sm'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have hover transition styles', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toHaveClass('transition-all', 'duration-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have focus ring styles', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /continue with google/i });
|
||||||
|
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should have button role for all providers', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{ name: 'google', display_name: 'Google' },
|
||||||
|
{ name: 'facebook', display_name: 'Facebook' },
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have descriptive button text', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should indicate loading state to screen readers', () => {
|
||||||
|
mockUseOAuthProviders.mockReturnValue({
|
||||||
|
data: [{ name: 'google', display_name: 'Google' }],
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseInitiateOAuth.mockReturnValue({
|
||||||
|
mutate: mockMutate,
|
||||||
|
isPending: true,
|
||||||
|
variables: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<OAuthButtons />, { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
827
frontend/src/components/__tests__/OnboardingWizard.test.tsx
Normal file
827
frontend/src/components/__tests__/OnboardingWizard.test.tsx
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for OnboardingWizard component
|
||||||
|
*
|
||||||
|
* Tests the multi-step onboarding wizard for new businesses.
|
||||||
|
* Covers:
|
||||||
|
* - Step navigation (welcome -> stripe -> complete)
|
||||||
|
* - Step indicator visualization
|
||||||
|
* - Welcome step rendering and buttons
|
||||||
|
* - Stripe Connect integration step
|
||||||
|
* - Completion step
|
||||||
|
* - Skip functionality
|
||||||
|
* - Auto-advance on Stripe connection
|
||||||
|
* - URL parameter handling (OAuth callback)
|
||||||
|
* - Loading states
|
||||||
|
* - Business update on completion
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter, useSearchParams } from 'react-router-dom';
|
||||||
|
import OnboardingWizard from '../OnboardingWizard';
|
||||||
|
import { Business } from '../../types';
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
const mockUsePaymentConfig = vi.fn();
|
||||||
|
const mockUseUpdateBusiness = vi.fn();
|
||||||
|
const mockSetSearchParams = vi.fn();
|
||||||
|
const mockSearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
vi.mock('../../hooks/usePayments', () => ({
|
||||||
|
usePaymentConfig: () => mockUsePaymentConfig(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useBusiness', () => ({
|
||||||
|
useUpdateBusiness: () => mockUseUpdateBusiness(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-router-dom', async () => {
|
||||||
|
const actual = await vi.importActual('react-router-dom');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useSearchParams: () => [mockSearchParams, mockSetSearchParams],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ConnectOnboardingEmbed component
|
||||||
|
vi.mock('../ConnectOnboardingEmbed', () => ({
|
||||||
|
default: ({ onComplete, onError }: any) => (
|
||||||
|
<div data-testid="connect-embed">
|
||||||
|
<button onClick={() => onComplete()}>Complete Embed</button>
|
||||||
|
<button onClick={() => onError('Test error')}>Trigger Error</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'onboarding.steps.welcome': 'Welcome',
|
||||||
|
'onboarding.steps.payments': 'Payments',
|
||||||
|
'onboarding.steps.complete': 'Complete',
|
||||||
|
'onboarding.welcome.title': `Welcome to ${params?.businessName}!`,
|
||||||
|
'onboarding.welcome.subtitle': "Let's get you set up",
|
||||||
|
'onboarding.welcome.whatsIncluded': "What's Included",
|
||||||
|
'onboarding.welcome.connectStripe': 'Connect to Stripe',
|
||||||
|
'onboarding.welcome.automaticPayouts': 'Automatic payouts',
|
||||||
|
'onboarding.welcome.pciCompliance': 'PCI compliance',
|
||||||
|
'onboarding.welcome.getStarted': 'Get Started',
|
||||||
|
'onboarding.welcome.skip': 'Skip for now',
|
||||||
|
'onboarding.stripe.title': 'Connect Stripe',
|
||||||
|
'onboarding.stripe.subtitle': `Accept payments with your ${params?.plan} plan`,
|
||||||
|
'onboarding.stripe.checkingStatus': 'Checking status...',
|
||||||
|
'onboarding.stripe.connected.title': 'Connected!',
|
||||||
|
'onboarding.stripe.connected.subtitle': 'Your account is ready',
|
||||||
|
'onboarding.stripe.continue': 'Continue',
|
||||||
|
'onboarding.stripe.doLater': 'Do this later',
|
||||||
|
'onboarding.complete.title': "You're all set!",
|
||||||
|
'onboarding.complete.subtitle': 'Ready to start',
|
||||||
|
'onboarding.complete.checklist.accountCreated': 'Account created',
|
||||||
|
'onboarding.complete.checklist.stripeConfigured': 'Stripe configured',
|
||||||
|
'onboarding.complete.checklist.readyForPayments': 'Ready for payments',
|
||||||
|
'onboarding.complete.goToDashboard': 'Go to Dashboard',
|
||||||
|
'onboarding.skipForNow': 'Skip for now',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test data factory
|
||||||
|
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Business',
|
||||||
|
subdomain: 'testbiz',
|
||||||
|
primaryColor: '#3B82F6',
|
||||||
|
secondaryColor: '#1E40AF',
|
||||||
|
whitelabelEnabled: false,
|
||||||
|
paymentsEnabled: false,
|
||||||
|
requirePaymentMethodToBook: false,
|
||||||
|
cancellationWindowHours: 24,
|
||||||
|
lateCancellationFeePercent: 50,
|
||||||
|
plan: 'Professional',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to wrap component with providers
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('OnboardingWizard', () => {
|
||||||
|
const mockOnComplete = vi.fn();
|
||||||
|
const mockOnSkip = vi.fn();
|
||||||
|
const mockRefetch = vi.fn();
|
||||||
|
const mockMutateAsync = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSearchParams.delete('connect');
|
||||||
|
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: null,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseUpdateBusiness.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal Rendering', () => {
|
||||||
|
it('should render modal overlay', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal has the fixed class for overlay
|
||||||
|
const modal = container.querySelector('.fixed');
|
||||||
|
expect(modal).toBeInTheDocument();
|
||||||
|
expect(modal).toHaveClass('inset-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render close button', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeButton = screen.getAllByRole('button').find(btn =>
|
||||||
|
btn.querySelector('svg')
|
||||||
|
);
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have scrollable content', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = container.querySelector('.overflow-auto');
|
||||||
|
expect(modal).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Step Indicator', () => {
|
||||||
|
it('should render step indicator with 3 steps', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepCircles = container.querySelectorAll('.rounded-full.w-8.h-8');
|
||||||
|
expect(stepCircles.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight current step', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeStep = container.querySelector('.bg-blue-600');
|
||||||
|
expect(activeStep).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show completed steps with checkmark', async () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move to next step
|
||||||
|
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||||
|
fireEvent.click(getStartedButton);
|
||||||
|
|
||||||
|
// First step should show green background after navigation
|
||||||
|
await waitFor(() => {
|
||||||
|
const completedStep = container.querySelector('.bg-green-500');
|
||||||
|
expect(completedStep).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Welcome Step', () => {
|
||||||
|
it('should render welcome step by default', () => {
|
||||||
|
const business = createMockBusiness({ name: 'Test Business' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/welcome to test business/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render sparkles icon', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconCircle = container.querySelector('.bg-gradient-to-br.from-blue-500');
|
||||||
|
expect(iconCircle).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show features list', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/connect to stripe/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/automatic payouts/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/pci compliance/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Get Started button', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /get started/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
expect(button).toHaveClass('bg-blue-600');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Skip button', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look for the skip button with exact text (not the close button with title)
|
||||||
|
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||||
|
expect(skipButtons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should advance to stripe step on Get Started click', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||||
|
fireEvent.click(getStartedButton);
|
||||||
|
|
||||||
|
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Stripe Connect Step', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Start at Stripe step
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
const getStartedButton = screen.getByRole('button', { name: /get started/i });
|
||||||
|
fireEvent.click(getStartedButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Stripe step after welcome', () => {
|
||||||
|
expect(screen.getByText(/connect stripe/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading while checking status', () => {
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to stripe step
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/checking status/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render ConnectOnboardingEmbed when not connected', () => {
|
||||||
|
expect(screen.getByTestId('connect-embed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show success message when already connected', () => {
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
// Component auto-advances to complete step when already connected
|
||||||
|
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Do This Later button', () => {
|
||||||
|
expect(screen.getByRole('button', { name: /do this later/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle embedded onboarding completion', async () => {
|
||||||
|
const completeButton = screen.getByText('Complete Embed');
|
||||||
|
fireEvent.click(completeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle embedded onboarding error', () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const errorButton = screen.getByText('Trigger Error');
|
||||||
|
fireEvent.click(errorButton);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Embedded onboarding error:', 'Test error');
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complete Step', () => {
|
||||||
|
it('should render complete step when Stripe is connected', () => {
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to stripe step - will auto-advance to complete since connected
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show completion checklist', () => {
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/stripe configured/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/ready for payments/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render Go to Dashboard button', () => {
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /go to dashboard/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onComplete when dashboard button clicked', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({});
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
const dashboardButton = screen.getByRole('button', { name: /go to dashboard/i });
|
||||||
|
fireEvent.click(dashboardButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||||
|
expect(mockOnComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Skip Functionality', () => {
|
||||||
|
it('should call onSkip when skip button clicked on welcome', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({});
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
onSkip={mockOnSkip}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the text-based skip button (not the X close button)
|
||||||
|
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||||
|
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||||
|
if (skipButton) {
|
||||||
|
fireEvent.click(skipButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||||
|
expect(mockOnSkip).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onComplete when no onSkip provided', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({});
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||||
|
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||||
|
if (skipButton) {
|
||||||
|
fireEvent.click(skipButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update business setup complete flag on skip', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({});
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipButtons = screen.getAllByRole('button', { name: /skip for now/i });
|
||||||
|
const skipButton = skipButtons.find(btn => btn.textContent?.includes('Skip for now'));
|
||||||
|
if (skipButton) {
|
||||||
|
fireEvent.click(skipButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalledWith({ initialSetupComplete: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close wizard when X button clicked', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({});
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find X button (close button)
|
||||||
|
const closeButtons = screen.getAllByRole('button');
|
||||||
|
const xButton = closeButtons.find(btn => btn.querySelector('svg') && !btn.textContent?.trim());
|
||||||
|
|
||||||
|
if (xButton) {
|
||||||
|
fireEvent.click(xButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auto-advance on Stripe Connection', () => {
|
||||||
|
it('should auto-advance to complete when Stripe connects', async () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
// Start not connected
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: null,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to stripe step
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
// Simulate Stripe connection
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/you're all set!/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URL Parameter Handling', () => {
|
||||||
|
it('should handle connect=complete query parameter', () => {
|
||||||
|
mockSearchParams.set('connect', 'complete');
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRefetch).toHaveBeenCalled();
|
||||||
|
expect(mockSetSearchParams).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle connect=refresh query parameter', () => {
|
||||||
|
mockSearchParams.set('connect', 'refresh');
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRefetch).toHaveBeenCalled();
|
||||||
|
expect(mockSetSearchParams).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading States', () => {
|
||||||
|
it('should disable dashboard button while updating', () => {
|
||||||
|
mockUseUpdateBusiness.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUsePaymentConfig.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
connect_account: {
|
||||||
|
status: 'active',
|
||||||
|
charges_enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const business = createMockBusiness();
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /get started/i }));
|
||||||
|
|
||||||
|
// Dashboard button should be disabled while updating
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const dashboardButton = buttons.find(btn => btn.textContent?.includes('Dashboard') || btn.querySelector('.animate-spin'));
|
||||||
|
if (dashboardButton) {
|
||||||
|
expect(dashboardButton).toBeDisabled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should have proper modal structure', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal overlay with fixed positioning
|
||||||
|
const modalOverlay = container.querySelector('.fixed.z-50');
|
||||||
|
expect(modalOverlay).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have semantic headings', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have accessible buttons', () => {
|
||||||
|
const business = createMockBusiness();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<OnboardingWizard
|
||||||
|
business={business}
|
||||||
|
onComplete={mockOnComplete}
|
||||||
|
/>,
|
||||||
|
{ wrapper: createWrapper() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user