26 Commits

Author SHA1 Message Date
poduck
e7733449dd Move tenant dashboard routes under /dashboard/ prefix
- Update App.tsx routes to use /dashboard/ prefix for all business user routes
- Add redirect from / to /dashboard for authenticated business users
- Update Sidebar.tsx navigation links with /dashboard/ prefix
- Update SettingsLayout.tsx settings navigation paths
- Update all help pages with /dashboard/help/ routes
- Update navigate() calls in components:
  - TrialBanner, PaymentSettingsSection, NotificationDropdown
  - BusinessLayout, UpgradePrompt, QuotaWarningBanner
  - QuotaOverageModal, OpenTicketsWidget, CreatePlugin
  - MyPlugins, PluginMarketplace, HelpTicketing
  - HelpGuide, Upgrade, TrialExpired
  - CustomDomainsSettings, QuotaSettings
- Fix hardcoded lvh.me URL in BusinessEditModal to use buildSubdomainUrl

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:48:45 -05:00
poduck
29bcb27e76 Add Puck site builder with preview and draft functionality
Frontend:
- Add comprehensive Puck component library (Layout, Content, Booking, Contact)
- Add Services component with usePublicServices hook integration
- Add 150+ icons to IconList component organized by category
- Add preview modal with viewport toggles (desktop/tablet/mobile)
- Add draft save/discard functionality with localStorage persistence
- Add draft status indicator in PageEditor toolbar
- Fix useSites hooks to use correct API URLs (/pages/{id}/)

Backend:
- Add SiteConfig model for theme, header, footer configuration
- Add Page SEO fields (meta_title, meta_description, og_image, etc.)
- Add puck_data validation for component structure
- Add create_missing_sites management command
- Fix PageViewSet to use EntitlementService for permissions
- Add comprehensive tests for site builder functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:32:11 -05:00
poduck
41caccd31a Add max_public_pages feature and site builder access control
- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10)
- Gate site builder access based on max_public_pages entitlement
- Auto-create Site with default booking page for new tenants
- Update PageEditor to use useEntitlements hook for permission checks
- Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor
- Add force update functionality for superusers in PlanEditorWizard
- Add comprehensive filters to all safe scripting get_* methods
- Update plugin documentation with full filter reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 00:27:15 -05:00
poduck
aa9d920612 Fix plan permissions using correct billing feature codes
The /api/business/current/ endpoint was using legacy permission key names
instead of the actual feature codes from the billing catalog. This caused
tenants on paid plans to be incorrectly locked out of features.

- Updated current_business_view to use correct feature codes (e.g.,
  'can_use_plugins' instead of 'plugins', 'sms_enabled' instead of
  'sms_reminders')
- Updated test to mock billing subscription and has_feature correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 21:48:09 -05:00
poduck
b384d9912a Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 21:00:54 -05:00
poduck
d25c578e59 Remove Tiers & Pricing tab from Platform Settings
Billing management is now handled on the dedicated Billing page.
Removed ~1050 lines of dead code including TiersSettingsTab, PlanRow,
and PlanModal components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 03:15:39 -05:00
poduck
a8c271b5e3 Add stackable add-ons with compounding integer features
- Add is_stackable field to AddOnProduct model for add-ons that can be
  purchased multiple times
- Add quantity field to SubscriptionAddOn for tracking purchase count
- Update EntitlementService to ADD integer add-on values to base plan
  (instead of max) and multiply by quantity for stackable add-ons
- Add feature selection to AddOnEditorModal using FeaturePicker component
- Add AddOnFeatureSerializer for nested feature CRUD on add-ons
- Fix Create Add-on button styling to use solid blue (was muted outline)
- Widen billing sidebar from 320px to 384px to prevent text wrapping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 03:10:53 -05:00
poduck
6afa3d7415 Refactor billing system: add-ons in wizard, remove business_tier, move to top-level app
- Add add-ons step to plan creation wizard (step 4 of 5)
- Remove redundant business_tier field from both billing systems:
  - commerce.billing.PlanVersion (new system)
  - platform.admin.SubscriptionPlan (legacy system)
- Move billing app from commerce.billing to top-level smoothschedule.billing
- Create BillingManagement page at /platform/billing with sidebar link
- Update plan matching logic to use plan.name instead of business_tier

Frontend:
- Add BillingManagement.tsx page
- Add BillingPlansTab.tsx with unified plan wizard
- Add useBillingAdmin.ts hooks
- Update TenantInviteModal, BusinessEditModal, BillingSettings to use plan.name
- Remove business_tier from usePlatformSettings, payments.ts types

Backend:
- Move billing app to smoothschedule/billing/
- Add migrations 0006-0009 for plan version settings, feature seeding, business_tier removal
- Add platform_admin migration 0013 to remove business_tier
- Update seed_subscription_plans command
- Update tasks.py to map tier by plan name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 01:25:43 -05:00
poduck
17786c5ec0 Merge feature/site-builder: Add booking flow and business hours
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:20:25 -05:00
poduck
4a66246708 Add booking flow, business hours, and dark mode support
Features:
- Complete multi-step booking flow with service selection, date/time picker,
  auth (login/signup with email verification), payment, and confirmation
- Business hours settings page for defining when business is open
- TimeBlock purpose field (BUSINESS_HOURS, CLOSURE, UNAVAILABLE)
- Service resource assignment with prep/takedown time buffers
- Availability checking respects business hours and service buffers
- Customer registration via email verification code

UI/UX:
- Full dark mode support for all booking components
- Separate first/last name fields in signup form
- Back buttons on each wizard step
- Removed auto-redirect from confirmation page

API:
- Public endpoints for services, availability, business hours
- Customer verification and registration endpoints
- Tenant lookup from X-Business-Subdomain header

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 20:20:18 -05:00
poduck
76c0d71aa0 Implement Site Builder with Puck and Booking Widget 2025-12-10 23:54:10 -05:00
poduck
384fe0fd86 Refactor Services page UI, disable full test coverage, and add WIP badges 2025-12-10 23:11:41 -05:00
poduck
4afcaa2b0d chore: Update uv.lock file
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 15:35:19 -05:00
poduck
8c52d6a275 refactor: Extract reusable UI components and add TDD documentation
- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples
- Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.)
- Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation)
- Update frontend/CLAUDE.md with component documentation and usage examples
- Refactor CreateTaskModal to use shared components and constants
- Fix test assertions to be more robust and accurate across all test files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 15:27:27 -05:00
poduck
18c9a69d75 fix: Store service prices in cents and fix contracts permission
- Update Service model to use price_cents/deposit_amount_cents as IntegerField
- Add @property methods for backward compatibility (price, deposit_amount return dollars)
- Update ServiceSerializer to convert dollars <-> cents on read/write
- Add migration to convert column types from numeric to integer
- Fix BusinessEditModal to properly use typed PlatformBusiness interface
- Add missing feature permission fields to PlatformBusiness TypeScript interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:37:13 -05:00
poduck
30ec150d90 feat: Add subscription/billing/entitlement system
Implements a complete billing system with:

Backend (Django):
- New billing app with models: Feature, Plan, PlanVersion, PlanFeature,
  Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn,
  EntitlementOverride, Invoice, InvoiceLine
- EntitlementService with resolution order: overrides > add-ons > plan
- Invoice generation service with immutable snapshots
- DRF API endpoints for entitlements, subscription, plans, invoices
- Data migrations to seed initial plans and convert existing tenants
- Bridge to legacy Tenant.has_feature() with fallback support
- 75 tests covering models, services, and API endpoints

Frontend (React):
- Billing API client (getEntitlements, getPlans, getInvoices, etc.)
- useEntitlements hook with hasFeature() and getLimit() helpers
- FeatureGate and LimitGate components for conditional rendering
- 29 tests for API, hook, and components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 03:10:30 -05:00
poduck
ba2c656243 perf: Optimize slow tests with shared tenant fixtures
- Add session-scoped shared_tenant and second_shared_tenant fixtures to conftest.py
- Refactor test_models.py and test_user_model.py to use shared fixtures
- Avoid ~40s migration overhead per tenant by reusing fixtures across tests
- Add pytest-xdist to dev dependencies for future parallel test execution

Previously 4 tests each created their own tenant (~40s each = ~160s total).
Now they share session-scoped tenants, reducing overhead significantly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 02:22:43 -05:00
poduck
485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- Create reusable FeaturesPermissionsEditor component with support for both
  subscription plan editing and individual business permission overrides
- Add can_use_contracts field to Tenant model for per-business contracts toggle
- Update PlatformSettings.tsx to use unified component for plan permissions
- Update BusinessEditModal.tsx to use unified component for business permissions
- Update PlatformBusinessUpdate API interface with all permission fields
- Add contracts permission mapping to tenant sync task

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 01:37:04 -05:00
poduck
2f6ea82114 fix: Update djstripe signal imports and fix test mocking
- Use correct WEBHOOK_SIGNALS dict access for payment intent signals
- Simplify webhook tests by removing complex djstripe module mocking
- Fix TimezoneSerializerMixin tests to expect dynamic field addition
- Update TenantViewSet tests to mock exclude() chain for public schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:24:37 -05:00
poduck
507222316c fix: Add django.setup() to deploy script plugin seeding
The python -c one-liner wasn't initializing Django's app registry,
causing AppRegistryNotReady error when calling get_tenant_model().
2025-12-09 14:29:08 -05:00
poduck
c5c108c76f fix: Exclude public schema from platform businesses listing 2025-12-09 14:21:15 -05:00
poduck
90fa628cb5 feat: Add customer appointment details modal and ATM-style currency input
- Add appointment detail modal to CustomerDashboard with payment info display
  - Shows service, date/time, duration, status, and notes
  - Displays payment summary: service price, deposit paid, payment made, amount due
  - Print receipt functionality with secure DOM manipulation
  - Cancel appointment button for upcoming appointments

- Add CurrencyInput component for ATM-style price entry
  - Digits entered as cents, shift left as more digits added (e.g., "1234" → $12.34)
  - Robust input validation: handles keyboard, mobile, paste, drop, IME
  - Only allows integer digits (0-9)

- Update useAppointments hook to map payment fields from backend
  - Converts amounts from cents to dollars for display

- Update Services page to use CurrencyInput for price and deposit fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 12:46:10 -05:00
poduck
7f389830f8 docs: Update README with comprehensive setup and deployment guide
- Updated project structure to reflect current domain-based organization
- Added detailed local development setup with lvh.me explanation
- Added production deployment instructions (quick deploy and fresh server)
- Documented environment variables configuration
- Added architecture diagrams for multi-tenancy and request flow
- Included troubleshooting section for common issues
- Updated role hierarchy documentation
- Added configuration files reference table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:53:02 -05:00
poduck
30909f3268 fix: Add WebSocket proxy configuration to nginx
The nginx.conf was missing a location block for /ws/ paths, causing
WebSocket connections to fall through to the SPA catch-all and return
index.html instead of proxying to Django/Daphne.

Added proper WebSocket proxy configuration with:
- HTTP/1.1 upgrade headers for WebSocket protocol
- 24-hour read timeout for long-lived connections
- Standard proxy headers for Django

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:46:37 -05:00
poduck
df45a6f5d7 fix: Use request.tenant for staff filtering in multi-tenant context
- UserTenantFilteredMixin now uses request.tenant (from django-tenants
  middleware) instead of request.user.tenant for filtering
- ResourceSerializer._get_valid_user uses request.tenant for validation
- Frontend useResources sends user_id instead of user field

This fixes 400 errors when creating staff resources because the tenant
context is now correctly derived from the subdomain being accessed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:31:18 -05:00
poduck
156ad09232 fix: Use request.tenant instead of request.user.tenant for user validation
Platform-level users (owners) may have tenant=None on their user record
but still access tenant subdomains. The _get_valid_user method now uses
request.tenant (from django-tenants middleware) which is set based on
the subdomain being accessed, not the user's tenant FK.

This fixes 400 Bad Request errors when platform users try to create
resources with staff assignments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:11:38 -05:00
424 changed files with 85751 additions and 7271 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"WebSearch"
],
"deny": [],
"ask": []
}
}

163
CLAUDE.md
View File

@@ -21,6 +21,169 @@
Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work.
## CRITICAL: Test-Driven Development (TDD) Required
**All code changes MUST follow TDD.** This is non-negotiable.
### TDD Workflow
1. **Write tests FIRST** before writing any implementation code
2. **Run tests** to verify they fail (red)
3. **Write minimal code** to make tests pass (green)
4. **Refactor** while keeping tests green
5. **Repeat** for each new feature or bug fix
### Coverage Requirements
| Target | Minimum | Goal |
|--------|---------|------|
| Backend (Django) | **80%** | 100% |
| Frontend (React) | **80%** | 100% |
### Running Tests with Coverage
**Backend (Django):**
```bash
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
# Run all tests with coverage
docker compose -f docker-compose.local.yml exec django pytest --cov --cov-report=term-missing
# Run tests for a specific app
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/ --cov=smoothschedule/scheduling/schedule
# Run a single test file
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/path/to/test_file.py -v
# Run tests matching a pattern
docker compose -f docker-compose.local.yml exec django pytest -k "test_create_resource" -v
```
**Frontend (React):**
```bash
cd /home/poduck/Desktop/smoothschedule2/frontend
# Run all tests with coverage
npm test -- --coverage
# Run tests in watch mode during development
npm test
# Run a single test file
npm test -- src/hooks/__tests__/useResources.test.ts
# Run tests matching a pattern
npm test -- -t "should create resource"
```
### Test File Organization
**Backend:**
```
smoothschedule/smoothschedule/{domain}/{app}/
├── models.py
├── views.py
├── serializers.py
└── tests/
├── __init__.py
├── test_models.py # Model unit tests
├── test_serializers.py # Serializer tests
├── test_views.py # API endpoint tests
└── factories.py # Test factories (optional)
```
**Frontend:**
```
frontend/src/
├── hooks/
│ ├── useResources.ts
│ └── __tests__/
│ └── useResources.test.ts
├── components/
│ ├── MyComponent.tsx
│ └── __tests__/
│ └── MyComponent.test.tsx
└── pages/
├── MyPage.tsx
└── __tests__/
└── MyPage.test.tsx
```
### What to Test
**Backend:**
- Model methods and properties
- Model validation (clean methods)
- Serializer validation
- API endpoints (all HTTP methods)
- Permission classes
- Custom querysets and managers
- Signals
- Celery tasks
- Utility functions
**Frontend:**
- Custom hooks (state changes, API calls)
- Component rendering
- User interactions (clicks, form submissions)
- Conditional rendering
- Error states
- Loading states
- API client functions
### TDD Example - Adding a New Feature
**Step 1: Write the test first**
```python
# Backend: test_views.py
def test_create_resource_with_schedule(self, api_client, tenant):
"""New feature: resources can have a default schedule."""
data = {
"name": "Test Resource",
"type": "STAFF",
"default_schedule": {
"monday": {"start": "09:00", "end": "17:00"},
"tuesday": {"start": "09:00", "end": "17:00"},
}
}
response = api_client.post("/api/resources/", data, format="json")
assert response.status_code == 201
assert response.data["default_schedule"]["monday"]["start"] == "09:00"
```
```typescript
// Frontend: useResources.test.ts
it('should create resource with schedule', async () => {
const { result } = renderHook(() => useCreateResource());
await act(async () => {
await result.current.mutateAsync({
name: 'Test Resource',
type: 'STAFF',
defaultSchedule: { monday: { start: '09:00', end: '17:00' } }
});
});
expect(mockApiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
default_schedule: expect.any(Object)
}));
});
```
**Step 2: Run tests - they should FAIL**
**Step 3: Write minimal implementation to make tests pass**
**Step 4: Refactor if needed while keeping tests green**
### Pre-Commit Checklist
Before committing ANY code:
1. [ ] Tests written BEFORE implementation
2. [ ] All tests pass
3. [ ] Coverage meets minimum threshold (80%)
4. [ ] No skipped or disabled tests without justification
## CRITICAL: Backend Runs in Docker
**NEVER run Django commands directly.** Always use Docker Compose:

655
README.md
View File

@@ -1,257 +1,470 @@
# SmoothSchedule - Multi-Tenant Scheduling Platform
A production-ready multi-tenant SaaS platform for resource scheduling and orchestration.
A production-ready multi-tenant SaaS platform for resource scheduling, appointments, and business management.
## 🎯 Features
## Features
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
- **Modern Stack**: Django 5.2 + React 18 + Vite
- **Docker Ready**: Complete production & development Docker Compose setup
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
- **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
- **Task Queue**: Celery + Redis for background jobs
- **Real-time**: Django Channels + WebSockets support
-**Production Ready**: Fully configured for deployment
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
- **Real-time Updates**: Django Channels + WebSockets
- **Background Tasks**: Celery + Redis
- **Auto SSL**: Let's Encrypt certificates via Traefik
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
- **Docker Ready**: Complete Docker Compose setup for dev and production
## 📚 Documentation
## Project Structure
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - **Manual step-by-step production deployment** (start here for fresh deployments)
- **[QUICK-REFERENCE.md](QUICK-REFERENCE.md)** - Common commands and quick start
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
```
smoothschedule2/
├── frontend/ # React + Vite + TypeScript
│ ├── src/
│ │ ├── api/ # API client and hooks
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # React Query hooks
│ │ ├── pages/ # Page components
│ │ └── types.ts # TypeScript interfaces
│ ├── nginx.conf # Production nginx config
│ └── Dockerfile.prod # Production frontend container
├── smoothschedule/ # Django backend
│ ├── config/ # Django settings
│ │ └── settings/
│ │ ├── base.py # Base settings
│ │ ├── local.py # Local development
│ │ └── production.py # Production settings
│ ├── smoothschedule/ # Django apps (domain-based)
│ │ ├── identity/ # Users, tenants, authentication
│ │ │ ├── core/ # Tenant, Domain, middleware
│ │ │ └── users/ # User model, MFA, auth
│ │ ├── scheduling/ # Core scheduling
│ │ │ ├── schedule/ # Resources, Events, Services
│ │ │ ├── contracts/ # E-signatures
│ │ │ └── analytics/ # Business analytics
│ │ ├── communication/ # Notifications, SMS, mobile
│ │ ├── commerce/ # Payments, tickets
│ │ └── platform/ # Admin, public API
│ ├── docker-compose.local.yml
│ └── docker-compose.production.yml
├── deploy.sh # Automated deployment script
└── CLAUDE.md # Development guide
```
## 🚀 Quick Start
---
### Local Development
## Local Development Setup
### Prerequisites
- **Docker** and **Docker Compose** (for backend)
- **Node.js 22+** and **npm** (for frontend)
- **Git**
### Step 1: Clone the Repository
```bash
# Start backend (Django in Docker)
git clone https://github.com/your-repo/smoothschedule.git
cd smoothschedule
```
### Step 2: Start the Backend (Django in Docker)
```bash
cd smoothschedule
# Start all backend services
docker compose -f docker-compose.local.yml up -d
# Start frontend (React with Vite)
cd ../frontend
npm install
npm run dev
# Wait for services to initialize (first time takes longer)
sleep 30
# Access the app
# Frontend: http://platform.lvh.me:5173
# Backend API: http://lvh.me:8000/api
# Run database migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Create a superuser (optional)
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
```
See [CLAUDE.md](CLAUDE.md) for detailed development instructions.
### Production Deployment
For **fresh deployments or complete reset**, follow [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for manual step-by-step instructions.
For **routine updates**, use the automated script:
### Step 3: Start the Frontend (React with Vite)
```bash
# Deploy to production server (code changes only)
./deploy.sh poduck@smoothschedule.com
cd ../frontend
# Install dependencies
npm install
# Start development server
npm run dev
```
See [PRODUCTION-READY.md](PRODUCTION-READY.md) for deployment checklist and [DEPLOYMENT.md](DEPLOYMENT.md) for detailed steps.
### Step 4: Access the Application
## 🏗️ Architecture
The application uses `lvh.me` (resolves to 127.0.0.1) for subdomain-based multi-tenancy:
| URL | Purpose |
|-----|---------|
| http://platform.lvh.me:5173 | Platform admin dashboard |
| http://demo.lvh.me:5173 | Demo tenant (if created) |
| http://lvh.me:8000/api/ | Backend API |
| http://lvh.me:8000/admin/ | Django admin |
**Why `lvh.me`?** Browsers don't allow cookies with `domain=.localhost`, but `lvh.me` resolves to 127.0.0.1 and allows proper cookie sharing across subdomains.
### Local Development Commands
```bash
# Backend commands (always use docker compose)
cd smoothschedule
# View logs
docker compose -f docker-compose.local.yml logs -f django
# Run migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
# Run tests
docker compose -f docker-compose.local.yml exec django pytest
# Stop all services
docker compose -f docker-compose.local.yml down
# Frontend commands
cd frontend
# Run tests
npm test
# Type checking
npm run typecheck
# Lint
npm run lint
```
### Creating a Test Tenant
```bash
cd smoothschedule
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.identity.core.models import Tenant, Domain
# Create tenant
tenant = Tenant.objects.create(
name="Demo Business",
schema_name="demo",
)
# Create domain
Domain.objects.create(
domain="demo.lvh.me",
tenant=tenant,
is_primary=True,
)
print(f"Created tenant: {tenant.name}")
print(f"Access at: http://demo.lvh.me:5173")
```
---
## Production Deployment
### Prerequisites
- Ubuntu/Debian server with Docker and Docker Compose
- Domain name with DNS configured:
- `A` record: `yourdomain.com` → Server IP
- `A` record: `*.yourdomain.com` → Server IP (wildcard)
- SSH access to the server
### Quick Deploy (Existing Server)
For routine updates to an existing production server:
```bash
# From your local machine
./deploy.sh user@yourdomain.com
# Or deploy specific services
./deploy.sh user@yourdomain.com nginx
./deploy.sh user@yourdomain.com django
```
### Fresh Server Deployment
#### Step 1: Server Setup
SSH into your server and install Docker:
```bash
ssh user@yourdomain.com
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# Logout and login for group changes
exit
ssh user@yourdomain.com
```
#### Step 2: Clone Repository
```bash
cd ~
git clone https://github.com/your-repo/smoothschedule.git smoothschedule
cd smoothschedule
```
#### Step 3: Configure Environment Variables
Create production environment files:
```bash
mkdir -p smoothschedule/.envs/.production
# Django configuration
cat > smoothschedule/.envs/.production/.django << 'EOF'
DJANGO_SECRET_KEY=your-random-secret-key-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=yourdomain.com,*.yourdomain.com
DJANGO_ADMIN_URL=your-secret-admin-path/
FRONTEND_URL=https://platform.yourdomain.com
PLATFORM_BASE_URL=https://platform.yourdomain.com
REDIS_URL=redis://redis:6379/0
CELERY_BROKER_URL=redis://redis:6379/0
# DigitalOcean Spaces (or S3)
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
DJANGO_AWS_STORAGE_BUCKET_NAME=your-bucket
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
DJANGO_AWS_S3_REGION_NAME=nyc3
# SSL
DJANGO_SECURE_SSL_REDIRECT=True
DJANGO_SESSION_COOKIE_SECURE=True
DJANGO_CSRF_COOKIE_SECURE=True
# Cloudflare (for wildcard SSL)
CF_DNS_API_TOKEN=your-cloudflare-api-token
EOF
# PostgreSQL configuration
cat > smoothschedule/.envs/.production/.postgres << 'EOF'
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=smoothschedule
POSTGRES_USER=smoothschedule_user
POSTGRES_PASSWORD=your-secure-database-password
EOF
```
#### Step 4: Build and Start
```bash
cd ~/smoothschedule/smoothschedule
# Build all images
docker compose -f docker-compose.production.yml build
# Start services
docker compose -f docker-compose.production.yml up -d
# Wait for startup
sleep 30
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Collect static files
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
# Create superuser
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
```
#### Step 5: Verify Deployment
```bash
# Check all containers are running
docker compose -f docker-compose.production.yml ps
# View logs
docker compose -f docker-compose.production.yml logs -f
# Test endpoints
curl https://yourdomain.com/api/health/
```
### Production URLs
| URL | Purpose |
|-----|---------|
| https://yourdomain.com | Marketing site |
| https://platform.yourdomain.com | Platform admin |
| https://*.yourdomain.com | Tenant subdomains |
| https://api.yourdomain.com | API (if configured) |
| https://yourdomain.com:5555 | Flower (Celery monitoring) |
### Production Management Commands
```bash
ssh user@yourdomain.com
cd ~/smoothschedule/smoothschedule
# View logs
docker compose -f docker-compose.production.yml logs -f django
# Restart services
docker compose -f docker-compose.production.yml restart
# Run migrations
docker compose -f docker-compose.production.yml exec django python manage.py migrate
# Django shell
docker compose -f docker-compose.production.yml exec django python manage.py shell
# Database backup
docker compose -f docker-compose.production.yml exec postgres pg_dump -U smoothschedule_user smoothschedule > backup.sql
```
---
## Architecture
### Multi-Tenancy Model
```
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
├─────────────────────────────────────────┤
public (shared schema) │
├─ Tenants │
├─ Domains │
│ ├─ Users │
└─ PermissionGrants │
├─────────────────────────────────────────┤
tenant_demo (schema for Demo Company) │
├─ Appointments │
│ ├─ Resources │
└─ Customers │
├─────────────────────────────────────────┤
│ tenant_acme (schema for Acme Corp) │
│ ├─ Appointments │
│ ├─ Resources │
│ └─ Customers │
└─────────────────────────────────────────┘
PostgreSQL Database
├── public (shared schema)
│ ├── Tenants
├── Domains
├── Users
└── PermissionGrants
├── demo (tenant schema)
├── Resources
│ ├── Events
├── Services
└── Customers
└── acme (tenant schema)
├── Resources
├── Events
└── ...
```
### Role Hierarchy
| Role | Level | Access Scope |
|---------------------|----------|---------------------------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Tenant users |
| TENANT_OWNER | Tenant | Own tenant (full access) |
| TENANT_MANAGER | Tenant | Own tenant |
| TENANT_STAFF | Tenant | Own tenant (limited) |
| CUSTOMER | Tenant | Own data only |
| Role | Level | Access |
|------|-------|--------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
| TENANT_OWNER | Tenant | Full tenant access |
| TENANT_MANAGER | Tenant | Most tenant features |
| TENANT_STAFF | Tenant | Limited tenant access |
| CUSTOMER | Tenant | Own data only |
### Masquerading Matrix
| Hijacker Role | Can Masquerade As |
|--------------------|----------------------------------|
| SUPERUSER | Anyone |
| PLATFORM_SUPPORT | Tenant users |
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
| TENANT_OWNER | Staff in same tenant |
| Others | No one |
**Security Rules:**
- Cannot hijack yourself
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
- Maximum depth: 1 (no hijack chains)
- All attempts logged to `logs/masquerade.log`
## 📁 Project Structure
### Request Flow
```
smoothschedule/
├── config/
└── settings.py # Multi-tenancy & security config
├── core/
├── models.py # Tenant, Domain, PermissionGrant
├── permissions.py # Hijack permission matrix
│ ├── middleware.py # Masquerade audit logging
│ └── admin.py # Django admin for core models
├── users/
│ ├── models.py # Custom User with 8-tier roles
│ └── admin.py # User admin with hijack button
├── logs/
│ ├── security.log # General security events
│ └── masquerade.log # Hijack activity (JSON)
└── setup_project.sh # Automated setup script
Browser → Traefik (SSL) → nginx (frontend) or django (API)
React SPA
/api/* → django:5000
/ws/* → django:5000 (WebSocket)
```
## 🔐 Security Features
### Audit Logging
All masquerade activity is logged in JSON format:
```json
{
"timestamp": "2024-01-15T10:30:00Z",
"action": "HIJACK_START",
"hijacker_email": "support@smoothschedule.com",
"hijacked_email": "customer@demo.com",
"ip_address": "192.168.1.1",
"session_key": "abc123..."
}
```
### Permission Grants (30-Minute Window)
Time-limited elevated permissions:
```python
from core.models import PermissionGrant
grant = PermissionGrant.create_grant(
grantor=admin_user,
grantee=support_user,
action="view_billing",
reason="Customer requested billing support",
duration_minutes=30,
)
# Check if active
if grant.is_active():
# Perform privileged action
pass
```
## 🧪 Testing Masquerading
1. Access Django Admin: `http://localhost:8000/admin/`
2. Create test users with different roles
3. Click "Hijack" button next to a user
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
## 📊 Admin Interface
- **Tenant Management**: View tenants, domains, subscription tiers
- **User Management**: Color-coded roles, masquerade buttons
- **Permission Grants**: Active/expired/revoked status, bulk revoke
- **Domain Verification**: AWS Route53 integration status
## 🛠️ Development
### Adding Tenant Apps
Edit `config/settings.py`:
```python
TENANT_APPS = [
'django.contrib.contenttypes',
'appointments', # Your app
'resources', # Your app
'billing', # Your app
]
```
### Custom Domain Setup
```python
domain = Domain.objects.create(
domain="app.customdomain.com",
tenant=tenant,
is_custom_domain=True,
route53_zone_id="Z1234567890ABC",
)
```
Then configure Route53 CNAME: `app.customdomain.com``smoothschedule.yourhost.com`
## 📖 Key Files Reference
| File | Purpose |
|------|---------|
| `setup_project.sh` | Automated project initialization |
| `config/settings.py` | Multi-tenancy, middleware, security config |
| `core/models.py` | Tenant, Domain, PermissionGrant models |
| `core/permissions.py` | Masquerading permission matrix |
| `core/middleware.py` | Audit logging for masquerading |
| `users/models.py` | Custom User with 8-tier roles |
## 📝 Important Notes
- **Django Admin**: The ONLY HTML interface (everything else is API)
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
## 🐛 Troubleshooting
**Cannot create tenant users:**
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
- Solution: Set `user.tenant = tenant_instance` before saving
**Hijack button doesn't appear:**
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
- Verify `HijackUserAdminMixin` in `users/admin.py`
- Ensure user has permission per matrix rules
**Migrations fail:**
- Run shared migrations first: `migrate_schemas --shared`
- Then run tenant migrations: `migrate_schemas`
## 📄 License
MIT
## 🤝 Contributing
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
---
**Built with ❤️ for multi-tenant SaaS perfection**
## Configuration Files
### Backend
| File | Purpose |
|------|---------|
| `smoothschedule/docker-compose.local.yml` | Local Docker services |
| `smoothschedule/docker-compose.production.yml` | Production Docker services |
| `smoothschedule/.envs/.local/` | Local environment variables |
| `smoothschedule/.envs/.production/` | Production environment variables |
| `smoothschedule/config/settings/` | Django settings |
| `smoothschedule/compose/production/traefik/traefik.yml` | Traefik routing config |
### Frontend
| File | Purpose |
|------|---------|
| `frontend/.env.development` | Local environment variables |
| `frontend/.env.production` | Production environment variables |
| `frontend/nginx.conf` | Production nginx config |
| `frontend/vite.config.ts` | Vite bundler config |
---
## Troubleshooting
### Backend won't start
```bash
# Check Docker logs
docker compose -f docker-compose.local.yml logs django
# Common issues:
# - Database not ready: wait longer, then restart django
# - Missing migrations: run migrate command
# - Port conflict: check if 8000 is in use
```
### Frontend can't connect to API
```bash
# Verify backend is running
curl http://lvh.me:8000/api/
# Check CORS settings in Django
# Ensure CORS_ALLOWED_ORIGINS includes http://platform.lvh.me:5173
```
### WebSockets disconnecting
```bash
# Check nginx has /ws/ proxy configured
# Verify django is running ASGI (Daphne)
# Check production traefik/nginx logs
```
### Multi-tenant issues
```bash
# Check tenant exists
docker compose exec django python manage.py shell
>>> from smoothschedule.identity.core.models import Tenant, Domain
>>> Tenant.objects.all()
>>> Domain.objects.all()
```
---
## Additional Documentation
- **[CLAUDE.md](CLAUDE.md)** - Development guide, coding standards, architecture details
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - Step-by-step manual deployment
---
## License
MIT

View File

@@ -184,6 +184,8 @@ if [[ "$SKIP_MIGRATE" != "true" ]]; then
echo ">>> Seeding/updating platform plugins for all tenants..."
docker compose -f docker-compose.production.yml exec -T django sh -c 'export DATABASE_URL=postgres://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@\${POSTGRES_HOST}:\${POSTGRES_PORT}/\${POSTGRES_DB} && python -c "
import django
django.setup()
from django_tenants.utils import get_tenant_model
from django.core.management import call_command
Tenant = get_tenant_model()

576
docs/SITE_BUILDER_DESIGN.md Normal file
View 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

View File

@@ -13,7 +13,10 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
├── frontend/ # This React frontend
│ ├── src/
│ │ ├── api/client.ts # Axios API client
│ │ ├── components/ # Reusable components
│ │ ├── components/ # Feature components
│ │ │ └── ui/ # Reusable UI components (see below)
│ │ ├── constants/ # Shared constants
│ │ │ └── schedulePresets.ts # Schedule/cron presets
│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.)
│ │ ├── pages/ # Page components
│ │ ├── types.ts # TypeScript interfaces
@@ -31,6 +34,125 @@ This is the React frontend for SmoothSchedule, a multi-tenant scheduling platfor
└── users/ # User management
```
## Reusable UI Components
All reusable UI components are in `src/components/ui/`. Import from the barrel file:
```typescript
import { Modal, FormInput, Button, Alert } from '../components/ui';
```
### Available Components
| Component | Description |
|-----------|-------------|
| **Modal** | Reusable modal dialog with header, body, footer |
| **ModalFooter** | Standardized modal footer with buttons |
| **FormInput** | Text input with label, error, hint support |
| **FormSelect** | Select dropdown with label, error support |
| **FormTextarea** | Textarea with label, error support |
| **FormCurrencyInput** | ATM-style currency input (cents) |
| **CurrencyInput** | Raw currency input component |
| **Button** | Button with variants, loading state, icons |
| **SubmitButton** | Pre-configured submit button |
| **Alert** | Alert banner (error, success, warning, info) |
| **ErrorMessage** | Error alert shorthand |
| **SuccessMessage** | Success alert shorthand |
| **TabGroup** | Tab navigation (default, pills, underline) |
| **StepIndicator** | Multi-step wizard indicator |
| **LoadingSpinner** | Loading spinner with variants |
| **PageLoading** | Full page loading state |
| **Card** | Card container with header/body/footer |
| **EmptyState** | Empty state placeholder |
| **Badge** | Status badges |
### Usage Examples
```typescript
// Modal with form
<Modal isOpen={isOpen} onClose={onClose} title="Edit Resource" size="lg">
<FormInput
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
required
/>
<FormSelect
label="Type"
value={type}
onChange={(e) => setType(e.target.value)}
options={[
{ value: 'STAFF', label: 'Staff' },
{ value: 'ROOM', label: 'Room' },
]}
/>
</Modal>
// Alert messages
{error && <ErrorMessage message={error} />}
{success && <SuccessMessage message="Saved successfully!" />}
// Tabs
<TabGroup
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'schedule', label: 'Schedule' },
]}
activeTab={activeTab}
onChange={setActiveTab}
/>
```
## Utility Hooks
### useCrudMutation
Factory hook for CRUD mutations with React Query:
```typescript
import { useCrudMutation, createCrudHooks } from '../hooks/useCrudMutation';
// Simple usage
const createResource = useCrudMutation<Resource, CreateResourceData>({
endpoint: '/resources',
method: 'POST',
invalidateKeys: [['resources']],
});
// Create all CRUD hooks at once
const { useCreate, useUpdate, useDelete } = createCrudHooks<Resource>('/resources', 'resources');
```
### useFormValidation
Schema-based form validation:
```typescript
import { useFormValidation, required, email, minLength } from '../hooks/useFormValidation';
const schema = {
email: [required('Email is required'), email('Invalid email')],
password: [required(), minLength(8, 'Min 8 characters')],
};
const { errors, validateForm, isValid } = useFormValidation(schema);
const handleSubmit = () => {
if (validateForm(formData)) {
// Submit
}
};
```
## Constants
### Schedule Presets
```typescript
import { SCHEDULE_PRESETS, TRIGGER_OPTIONS, OFFSET_PRESETS } from '../constants/schedulePresets';
```
## Local Development Domain Setup
### Why lvh.me instead of localhost?

View File

@@ -63,6 +63,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket connections to Django (Daphne/ASGI)
location /ws/ {
proxy_pass http://django:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
location /static/ {
proxy_pass http://django:5000;

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
@@ -39,6 +40,7 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -578,6 +580,17 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/abstract": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.1.21.tgz",
"integrity": "sha512-6sJut6/D21xPIK8EFMu+JJeF+fBCOmQKN1BRpeUYFi5m9P1CJpTYbBwfI107h7PHObI6a5bsckiKkRpF2orHpw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/geometry": "^0.1.21",
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -590,6 +603,17 @@
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/collision": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.1.21.tgz",
"integrity": "sha512-9AJ4NbuwGDexxMCZXZyKdNQhbAe93p6C6IezQaDaWmdCqZHMHmC3+ul7pGefBQfOooSarGwIf8Bn182o9SMa1A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.21",
"@dnd-kit/geometry": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
@@ -605,6 +629,65 @@
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/dom": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.1.21.tgz",
"integrity": "sha512-6UDc1y2Y3oLQKArGlgCrZxz5pdEjRSiQujXOn5JdbuWvKqTdUR5RTYDeicr+y2sVm3liXjTqs3WlUoV+eqhqUQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.21",
"@dnd-kit/collision": "^0.1.21",
"@dnd-kit/geometry": "^0.1.21",
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/geometry": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.1.21.tgz",
"integrity": "sha512-Tir97wNJbopN2HgkD7AjAcoB3vvrVuUHvwdPALmNDUH0fWR637c4MKQ66YjjZAbUEAR8KL6mlDiHH4MzTLd7CQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/state": "^0.1.21",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/helpers": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@dnd-kit/helpers/-/helpers-0.1.18.tgz",
"integrity": "sha512-k4hVXIb8ysPt+J0KOxbBTc6rG0JSlsrNevI/fCHLbyXvEyj1imxl7yOaAQX13cAZnte88db6JvbgsSWlVjtxbw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.18",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/react": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.1.18.tgz",
"integrity": "sha512-OCeCO9WbKnN4rVlEOEe9QWxSIFzP0m/fBFmVYfu2pDSb4pemRkfrvCsI/FH3jonuESYS8qYnN9vc8Vp3EiCWCA==",
"license": "MIT",
"dependencies": {
"@dnd-kit/abstract": "^0.1.18",
"@dnd-kit/dom": "^0.1.18",
"@dnd-kit/state": "^0.1.18",
"tslib": "^2.6.2"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@dnd-kit/state": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.1.21.tgz",
"integrity": "sha512-pdhntEPvn/QttcF295bOJpWiLsRqA/Iczh1ODOJUxGiR+E4GkYVz9VapNNm9gDq6ST0tr/e1Q2xBztUHlJqQgA==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.10.0",
"tslib": "^2.6.2"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
@@ -1319,6 +1402,27 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@measured/puck": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@measured/puck/-/puck-0.20.2.tgz",
"integrity": "sha512-/GuzlsGs1T2S3lY9so4GyHpDBlWnC1h/4rkYuelrLNHvacnXBZyn50hvgRhWAqlLn/xOuJvJeuY740Zemxdt3Q==",
"license": "MIT",
"dependencies": {
"@dnd-kit/helpers": "0.1.18",
"@dnd-kit/react": "0.1.18",
"deep-diff": "^1.0.2",
"fast-deep-equal": "^3.1.3",
"flat": "^5.0.2",
"object-hash": "^3.0.0",
"react-hotkeys-hook": "^4.6.1",
"use-debounce": "^9.0.4",
"uuid": "^9.0.1",
"zustand": "^5.0.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
@@ -1335,6 +1439,16 @@
"node": ">=18"
}
},
"node_modules/@preact/signals-core": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.1.tgz",
"integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@react-google-maps/api": {
"version": "2.20.7",
"resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.7.tgz",
@@ -2071,7 +2185,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2160,8 +2273,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2598,7 +2710,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3260,6 +3371,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3300,8 +3417,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -3774,6 +3890,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"license": "BSD-3-Clause",
"bin": {
"flat": "cli.js"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -4972,7 +5097,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5179,6 +5303,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5415,7 +5548,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5431,7 +5563,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -5444,8 +5575,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prismjs": {
"version": "1.30.0",
@@ -5568,6 +5698,16 @@
"react-dom": ">=16"
}
},
"node_modules/react-hotkeys-hook": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.2.tgz",
"integrity": "sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.1",
"react-dom": ">=16.8.1"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
@@ -6218,6 +6358,18 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-debounce": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz",
"integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -6227,6 +6379,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
@@ -6599,6 +6764,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -6,6 +6,7 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@measured/puck": "^0.20.2",
"@react-google-maps/api": "^2.20.7",
"@stripe/connect-js": "^3.3.31",
"@stripe/react-connect-js": "^3.3.31",
@@ -35,6 +36,7 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",

View File

@@ -63,6 +63,7 @@ const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/Platfor
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
@@ -110,6 +111,10 @@ const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Im
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
// Settings pages
const SettingsLayout = React.lazy(() => import('./layouts/SettingsLayout'));
@@ -124,6 +129,7 @@ const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'))
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
@@ -346,7 +352,8 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<TenantLandingPage subdomain={currentSubdomain} />} />
<Route path="/" element={<PublicPage />} />
<Route path="/book" element={<BookingFlow />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
@@ -490,7 +497,10 @@ const AppContent: React.FC = () => {
<Route path="/help/plugins" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
{user.role === 'superuser' && (
<Route path="/platform/settings" element={<PlatformSettings />} />
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
@@ -624,7 +634,7 @@ const AppContent: React.FC = () => {
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
// Allowed routes when trial is expired
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
const allowedWhenExpired = ['/dashboard/trial-expired', '/dashboard/upgrade', '/dashboard/settings', '/dashboard/profile'];
const currentPath = window.location.pathname;
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
@@ -633,15 +643,15 @@ const AppContent: React.FC = () => {
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
<Route path="/dashboard/upgrade" element={<Upgrade />} />
<Route path="/dashboard/profile" element={<ProfileSettings />} />
{/* Trial-expired users can access billing settings to upgrade */}
<Route
path="/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/upgrade" /> : <Navigate to="/trial-expired" />}
path="/dashboard/settings/*"
element={hasAccess(['owner']) ? <Navigate to="/dashboard/upgrade" /> : <Navigate to="/dashboard/trial-expired" />}
/>
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
<Route path="*" element={<Navigate to="/dashboard/trial-expired" replace />} />
</Routes>
</Suspense>
);
@@ -662,30 +672,33 @@ const AppContent: React.FC = () => {
/>
}
>
{/* Redirect root to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Trial and Upgrade Routes */}
<Route path="/trial-expired" element={<TrialExpired />} />
<Route path="/upgrade" element={<Upgrade />} />
<Route path="/dashboard/trial-expired" element={<TrialExpired />} />
<Route path="/dashboard/upgrade" element={<Upgrade />} />
{/* Regular Routes */}
<Route
path="/"
path="/dashboard"
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/>
{/* Staff Schedule - vertical timeline view */}
<Route
path="/my-schedule"
path="/dashboard/my-schedule"
element={
hasAccess(['staff']) ? (
<StaffSchedule user={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route path="/scheduler" element={<Scheduler />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/dashboard/scheduler" element={<Scheduler />} />
<Route path="/dashboard/tickets" element={<Tickets />} />
<Route
path="/help"
path="/dashboard/help"
element={
user.role === 'staff' ? (
<StaffHelp user={user} />
@@ -694,189 +707,210 @@ const AppContent: React.FC = () => {
)
}
/>
<Route path="/help/guide" element={<HelpGuide />} />
<Route path="/help/ticketing" element={<HelpTicketing />} />
<Route path="/help/api" element={<HelpApiDocs />} />
<Route path="/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/help/email" element={<HelpEmailSettings />} />
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
{/* New help pages */}
<Route path="/help/dashboard" element={<HelpDashboard />} />
<Route path="/help/scheduler" element={<HelpScheduler />} />
<Route path="/help/tasks" element={<HelpTasks />} />
<Route path="/help/customers" element={<HelpCustomers />} />
<Route path="/help/services" element={<HelpServices />} />
<Route path="/help/resources" element={<HelpResources />} />
<Route path="/help/staff" element={<HelpStaff />} />
<Route path="/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/help/messages" element={<HelpMessages />} />
<Route path="/help/payments" element={<HelpPayments />} />
<Route path="/help/contracts" element={<HelpContracts />} />
<Route path="/help/plugins" element={<HelpPlugins />} />
<Route path="/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/help/settings/quota" element={<HelpSettingsQuota />} />
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
<Route path="/dashboard/help/scheduler" element={<HelpScheduler />} />
<Route path="/dashboard/help/tasks" element={<HelpTasks />} />
<Route path="/dashboard/help/customers" element={<HelpCustomers />} />
<Route path="/dashboard/help/services" element={<HelpServices />} />
<Route path="/dashboard/help/resources" element={<HelpResources />} />
<Route path="/dashboard/help/staff" element={<HelpStaff />} />
<Route path="/dashboard/help/time-blocks" element={<HelpTimeBlocks />} />
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
<Route path="/dashboard/help/settings/appearance" element={<HelpSettingsAppearance />} />
<Route path="/dashboard/help/settings/email" element={<HelpSettingsEmail />} />
<Route path="/dashboard/help/settings/domains" element={<HelpSettingsDomains />} />
<Route path="/dashboard/help/settings/api" element={<HelpSettingsApi />} />
<Route path="/dashboard/help/settings/auth" element={<HelpSettingsAuth />} />
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
<Route
path="/plugins/marketplace"
path="/dashboard/plugins/marketplace"
element={
hasAccess(['owner', 'manager']) ? (
<PluginMarketplace />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/plugins/my-plugins"
path="/dashboard/plugins/my-plugins"
element={
hasAccess(['owner', 'manager']) ? (
<MyPlugins />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/plugins/create"
path="/dashboard/plugins/create"
element={
hasAccess(['owner', 'manager']) ? (
<CreatePlugin />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/tasks"
path="/dashboard/tasks"
element={
hasAccess(['owner', 'manager']) ? (
<Tasks />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/email-templates"
path="/dashboard/email-templates"
element={
hasAccess(['owner', 'manager']) ? (
<EmailTemplates />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route path="/support" element={<PlatformSupport />} />
<Route path="/dashboard/support" element={<PlatformSupport />} />
<Route
path="/customers"
path="/dashboard/customers"
element={
hasAccess(['owner', 'manager']) ? (
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/services"
path="/dashboard/services"
element={
hasAccess(['owner', 'manager']) ? (
<Services />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/resources"
path="/dashboard/resources"
element={
hasAccess(['owner', 'manager']) ? (
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/staff"
path="/dashboard/staff"
element={
hasAccess(['owner', 'manager']) ? (
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/time-blocks"
path="/dashboard/time-blocks"
element={
hasAccess(['owner', 'manager']) ? (
<TimeBlocks />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/my-availability"
path="/dashboard/locations"
element={
hasAccess(['owner', 'manager']) ? (
<Locations />
) : (
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/my-availability"
element={
hasAccess(['staff', 'resource']) ? (
<MyAvailability user={user} />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/contracts"
path="/dashboard/contracts"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<Contracts />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/contracts/templates"
path="/dashboard/contracts/templates"
element={
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
<ContractTemplates />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/payments"
path="/dashboard/payments"
element={
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/dashboard" />
}
/>
<Route
path="/messages"
path="/dashboard/messages"
element={
hasAccess(['owner', 'manager']) && user?.can_send_messages ? (
<Messages />
) : (
<Navigate to="/" />
<Navigate to="/dashboard" />
)
}
/>
<Route
path="/dashboard/site-editor"
element={
hasAccess(['owner', 'manager']) ? (
<PageEditor />
) : (
<Navigate to="/dashboard" />
)
}
/>
{/* Settings Routes with Nested Layout */}
{hasAccess(['owner']) ? (
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/settings/general" replace />} />
<Route path="/dashboard/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="/dashboard/settings/general" replace />} />
<Route path="general" element={<GeneralSettings />} />
<Route path="branding" element={<BrandingSettings />} />
<Route path="resource-types" element={<ResourceTypesSettings />} />
<Route path="booking" element={<BookingSettings />} />
<Route path="business-hours" element={<BusinessHoursSettings />} />
<Route path="email-templates" element={<EmailTemplates />} />
<Route path="custom-domains" element={<CustomDomainsSettings />} />
<Route path="api" element={<ApiSettings />} />
@@ -887,11 +921,11 @@ const AppContent: React.FC = () => {
<Route path="quota" element={<QuotaSettings />} />
</Route>
) : (
<Route path="/settings/*" element={<Navigate to="/" />} />
<Route path="/dashboard/settings/*" element={<Navigate to="/dashboard" />} />
)}
<Route path="/profile" element={<ProfileSettings />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" />} />
<Route path="/dashboard/profile" element={<ProfileSettings />} />
<Route path="/dashboard/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Route>
</Routes>
</Suspense>

View File

@@ -0,0 +1,214 @@
/**
* Tests for Billing API client functions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '../client';
import {
getEntitlements,
getCurrentSubscription,
getPlans,
getAddOns,
getInvoices,
getInvoice,
Entitlements,
Subscription,
PlanVersion,
AddOnProduct,
Invoice,
} from '../billing';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('Billing API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getEntitlements', () => {
it('fetches entitlements from /api/me/entitlements/', async () => {
const mockEntitlements: Entitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
max_resources: 25,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
const result = await getEntitlements();
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
expect(result).toEqual(mockEntitlements);
});
it('returns empty object on error', async () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
const result = await getEntitlements();
expect(result).toEqual({});
});
});
describe('getCurrentSubscription', () => {
it('fetches subscription from /api/me/subscription/', async () => {
const mockSubscription: Subscription = {
id: 1,
status: 'active',
plan_version: {
id: 10,
name: 'Pro Plan v1',
is_legacy: false,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
current_period_start: '2024-01-01T00:00:00Z',
current_period_end: '2024-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
const result = await getCurrentSubscription();
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
expect(result).toEqual(mockSubscription);
});
it('returns null when no subscription (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getCurrentSubscription();
expect(result).toBeNull();
});
});
describe('getPlans', () => {
it('fetches public plans from /api/billing/plans/', async () => {
const mockPlans: PlanVersion[] = [
{
id: 1,
name: 'Free Plan',
is_legacy: false,
is_public: true,
plan: { code: 'free', name: 'Free' },
price_monthly_cents: 0,
price_yearly_cents: 0,
},
{
id: 2,
name: 'Pro Plan',
is_legacy: false,
is_public: true,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
const result = await getPlans();
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
expect(result).toEqual(mockPlans);
expect(result).toHaveLength(2);
});
});
describe('getAddOns', () => {
it('fetches active add-ons from /api/billing/addons/', async () => {
const mockAddOns: AddOnProduct[] = [
{
id: 1,
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
price_one_time_cents: 0,
is_stackable: false,
is_active: true,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
const result = await getAddOns();
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
expect(result).toEqual(mockAddOns);
});
});
describe('getInvoices', () => {
it('fetches invoices from /api/billing/invoices/', async () => {
const mockInvoices: Invoice[] = [
{
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
const result = await getInvoices();
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
expect(result).toEqual(mockInvoices);
});
});
describe('getInvoice', () => {
it('fetches a single invoice by ID', async () => {
const mockInvoice: Invoice = {
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
lines: [
{
id: 1,
line_type: 'plan',
description: 'Pro Plan',
quantity: 1,
unit_amount: 7900,
total_amount: 7900,
},
],
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
const result = await getInvoice(1);
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
expect(result).toEqual(mockInvoice);
});
it('returns null when invoice not found (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getInvoice(999);
expect(result).toBeNull();
});
});
});

188
frontend/src/api/billing.ts Normal file
View 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;
}
};

View File

@@ -443,7 +443,6 @@ export interface SubscriptionPlan {
name: string;
description: string;
plan_type: 'base' | 'addon';
business_tier: string;
price_monthly: number | null;
price_yearly: number | null;
features: string[];

View File

@@ -25,6 +25,7 @@ export interface PlatformBusiness {
owner: PlatformBusinessOwner | null;
max_users: number;
max_resources: number;
max_pages: number;
contact_email?: string;
phone?: string;
// Platform permissions
@@ -33,6 +34,25 @@ export interface PlatformBusiness {
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
// Feature permissions (optional - returned by API but may not always be present in tests)
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
}
export interface PlatformBusinessUpdate {
@@ -41,11 +61,39 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
max_pages?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_customize_booking_page?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {
@@ -55,6 +103,7 @@ export interface PlatformBusinessCreate {
is_active?: boolean;
max_users?: number;
max_resources?: number;
max_pages?: number;
contact_email?: string;
phone?: string;
can_manage_oauth_credentials?: boolean;
@@ -103,6 +152,27 @@ export const updateBusiness = async (
return response.data;
};
/**
* Change a business's subscription plan (platform admin only)
*/
export interface ChangePlanResponse {
detail: string;
plan_code: string;
plan_name: string;
version: number;
}
export const changeBusinessPlan = async (
businessId: number,
planCode: string
): Promise<ChangePlanResponse> => {
const response = await apiClient.post<ChangePlanResponse>(
`/platform/businesses/${businessId}/change_plan/`,
{ plan_code: planCode }
);
return response.data;
};
/**
* Create a new business (platform admin only)
*/
@@ -280,3 +350,46 @@ export const acceptInvitation = async (
);
return response.data;
};
// ============================================================================
// Tenant Custom Tier
// ============================================================================
import { TenantCustomTier } from '../types';
/**
* Get a business's custom tier (if it exists)
*/
export const getCustomTier = async (businessId: number): Promise<TenantCustomTier | null> => {
try {
const response = await apiClient.get<TenantCustomTier>(`/platform/businesses/${businessId}/custom_tier/`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
};
/**
* Update or create a custom tier for a business
*/
export const updateCustomTier = async (
businessId: number,
features: Record<string, boolean | number>,
notes?: string
): Promise<TenantCustomTier> => {
const response = await apiClient.put<TenantCustomTier>(
`/platform/businesses/${businessId}/custom_tier/`,
{ features, notes }
);
return response.data;
};
/**
* Delete a business's custom tier
*/
export const deleteCustomTier = async (businessId: number): Promise<void> => {
await apiClient.delete(`/platform/businesses/${businessId}/custom_tier/`);
};

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

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

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

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

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

View File

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

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

View File

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

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

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

View File

@@ -1,8 +1,16 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays, ChevronDown, ChevronUp } from 'lucide-react';
import { X, Calendar, Clock, RotateCw, Zap, CalendarDays } from 'lucide-react';
import toast from 'react-hot-toast';
import {
SCHEDULE_PRESETS,
TRIGGER_OPTIONS,
OFFSET_PRESETS,
getScheduleDescription,
getEventTimingDescription,
} from '../constants/schedulePresets';
import { ErrorMessage } from './ui';
interface PluginInstallation {
id: string;
@@ -14,11 +22,11 @@ interface PluginInstallation {
version: string;
author_name: string;
logo_url?: string;
template_variables: Record<string, any>;
template_variables: Record<string, unknown>;
scheduled_task?: number;
scheduled_task_name?: string;
installed_at: string;
config_values: Record<string, any>;
config_values: Record<string, unknown>;
has_update: boolean;
}
@@ -28,65 +36,6 @@ interface CreateTaskModalProps {
onSuccess: () => void;
}
// Schedule presets for visual selection
interface SchedulePreset {
id: string;
label: string;
description: string;
type: 'INTERVAL' | 'CRON';
interval_minutes?: number;
cron_expression?: string;
}
const SCHEDULE_PRESETS: SchedulePreset[] = [
// Interval-based
{ id: 'every_15min', label: 'Every 15 minutes', description: 'Runs 4 times per hour', type: 'INTERVAL', interval_minutes: 15 },
{ id: 'every_30min', label: 'Every 30 minutes', description: 'Runs twice per hour', type: 'INTERVAL', interval_minutes: 30 },
{ id: 'every_hour', label: 'Every hour', description: 'Runs 24 times per day', type: 'INTERVAL', interval_minutes: 60 },
{ id: 'every_2hours', label: 'Every 2 hours', description: 'Runs 12 times per day', type: 'INTERVAL', interval_minutes: 120 },
{ id: 'every_4hours', label: 'Every 4 hours', description: 'Runs 6 times per day', type: 'INTERVAL', interval_minutes: 240 },
{ id: 'every_6hours', label: 'Every 6 hours', description: 'Runs 4 times per day', type: 'INTERVAL', interval_minutes: 360 },
{ id: 'every_12hours', label: 'Twice daily', description: 'Runs at midnight and noon', type: 'INTERVAL', interval_minutes: 720 },
// Cron-based (specific times)
{ id: 'daily_midnight', label: 'Daily at midnight', description: 'Runs once per day at 12:00 AM', type: 'CRON', cron_expression: '0 0 * * *' },
{ id: 'daily_9am', label: 'Daily at 9 AM', description: 'Runs once per day at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * *' },
{ id: 'daily_6pm', label: 'Daily at 6 PM', description: 'Runs once per day at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * *' },
{ id: 'weekdays_9am', label: 'Weekdays at 9 AM', description: 'Mon-Fri at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1-5' },
{ id: 'weekdays_6pm', label: 'Weekdays at 6 PM', description: 'Mon-Fri at 6:00 PM', type: 'CRON', cron_expression: '0 18 * * 1-5' },
{ id: 'weekly_sunday', label: 'Weekly on Sunday', description: 'Every Sunday at midnight', type: 'CRON', cron_expression: '0 0 * * 0' },
{ id: 'weekly_monday', label: 'Weekly on Monday', description: 'Every Monday at 9:00 AM', type: 'CRON', cron_expression: '0 9 * * 1' },
{ id: 'monthly_1st', label: 'Monthly on the 1st', description: 'First day of each month', type: 'CRON', cron_expression: '0 0 1 * *' },
];
// Event trigger options (same as EventAutomations component)
interface TriggerOption {
value: string;
label: string;
}
interface OffsetPreset {
value: number;
label: string;
}
const TRIGGER_OPTIONS: TriggerOption[] = [
{ value: 'before_start', label: 'Before Start' },
{ value: 'at_start', label: 'At Start' },
{ value: 'after_start', label: 'After Start' },
{ value: 'after_end', label: 'After End' },
{ value: 'on_complete', label: 'When Completed' },
{ value: 'on_cancel', label: 'When Canceled' },
];
const OFFSET_PRESETS: OffsetPreset[] = [
{ value: 0, label: 'Immediately' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
];
// Task type: scheduled or event-based
type TaskType = 'scheduled' | 'event';
@@ -154,41 +103,16 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
setStep(2);
};
const getScheduleDescription = () => {
if (scheduleMode === 'onetime') {
if (runAtDate && runAtTime) {
return `Once on ${new Date(`${runAtDate}T${runAtTime}`).toLocaleString()}`;
}
return 'Select date and time';
}
if (scheduleMode === 'advanced') {
return `Custom: ${customCron}`;
}
const preset = SCHEDULE_PRESETS.find(p => p.id === selectedPreset);
return preset?.description || 'Select a schedule';
};
// Use shared helper functions from constants
const scheduleDescriptionText = getScheduleDescription(
scheduleMode,
selectedPreset,
runAtDate,
runAtTime,
customCron
);
const getEventTimingDescription = () => {
const trigger = TRIGGER_OPTIONS.find(t => t.value === selectedTrigger);
if (!trigger) return 'Select timing';
if (selectedTrigger === 'on_complete') return 'When event is completed';
if (selectedTrigger === 'on_cancel') return 'When event is canceled';
if (selectedOffset === 0) {
if (selectedTrigger === 'before_start') return 'At event start';
if (selectedTrigger === 'at_start') return 'At event start';
if (selectedTrigger === 'after_start') return 'At event start';
if (selectedTrigger === 'after_end') return 'At event end';
}
const offsetLabel = OFFSET_PRESETS.find(o => o.value === selectedOffset)?.label || `${selectedOffset} min`;
if (selectedTrigger === 'before_start') return `${offsetLabel} before event starts`;
if (selectedTrigger === 'at_start' || selectedTrigger === 'after_start') return `${offsetLabel} after event starts`;
if (selectedTrigger === 'after_end') return `${offsetLabel} after event ends`;
return trigger.label;
};
const eventTimingDescriptionText = getEventTimingDescription(selectedTrigger, selectedOffset);
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
@@ -543,7 +467,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-800 dark:text-green-200">
<strong>Schedule:</strong> {getScheduleDescription()}
<strong>Schedule:</strong> {scheduleDescriptionText}
</span>
</div>
</div>
@@ -657,7 +581,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
<div className="flex items-center gap-2">
<CalendarDays className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200">
<strong>Runs:</strong> {getEventTimingDescription()}
<strong>Runs:</strong> {eventTimingDescriptionText}
</span>
</div>
</div>
@@ -665,11 +589,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{error && <ErrorMessage message={error} />}
</div>
)}
</div>

View 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 &rarr;
</a>
</div>
);
};
export default FeatureGate;

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

View File

@@ -59,7 +59,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
// Handle time-off request notifications - navigate to time blocks page
// Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/time-blocks');
navigate('/dashboard/time-blocks');
setIsOpen(false);
return;
}
@@ -224,7 +224,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
</button>
<button
onClick={() => {
navigate('/notifications');
navigate('/dashboard/notifications');
setIsOpen(false);
}}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"

View File

@@ -235,7 +235,7 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
</ul>
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate('/settings/billing')}
onClick={() => navigate('/dashboard/settings/billing')}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<ArrowUpRight size={16} />

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -75,6 +75,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
<CreditCard size={18} className="shrink-0" />
{!isCollapsed && <span>Billing</span>}
</Link>
<Link to="/platform/settings" className={getNavClass('/platform/settings')} title={t('nav.platformSettings')}>
<Settings size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.platformSettings')}</span>}

View File

@@ -244,7 +244,7 @@ const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismi
{t('quota.modal.dismissButton', 'Remind Me Later')}
</button>
<Link
to="/settings/quota"
to="/dashboard/settings/quota"
onClick={handleDismiss}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>

View File

@@ -78,7 +78,7 @@ const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDis
</div>
<div className="flex items-center gap-2">
<Link
to="/settings/quota"
to="/dashboard/settings/quota"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isCritical || isUrgent
? 'bg-white/20 hover:bg-white/30 text-white'

View File

@@ -217,8 +217,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const drSmith = screen.getByText('Dr. Smith').closest('div');
const confRoom = screen.getByText('Conference Room A').closest('div');
// The height style is on the resource row container (3 levels up from the text)
const drSmith = screen.getByText('Dr. Smith').closest('[style*="height"]');
const confRoom = screen.getByText('Conference Room A').closest('[style*="height"]');
expect(drSmith).toHaveStyle({ height: '100px' });
expect(confRoom).toHaveStyle({ height: '120px' });
@@ -420,7 +421,8 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointment = screen.getByText('John Doe').closest('div');
// Navigate up to the draggable container which has the svg
const appointment = screen.getByText('John Doe').closest('.cursor-grab');
const svg = appointment?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
@@ -544,8 +546,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('cursor-grab');
// Use the specific class selector since .closest('div') returns the inner div
const appointmentCard = screen.getByText('John Doe').closest('.cursor-grab');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply active cursor-grabbing class to draggable items', () => {
@@ -558,8 +561,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('active:cursor-grabbing');
// Verify the draggable container has the active:cursor-grabbing class
const appointmentCard = screen.getByText('John Doe').closest('[class*="active:cursor-grabbing"]');
expect(appointmentCard).toBeInTheDocument();
});
it('should render pending items with orange left border', () => {
@@ -572,8 +576,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('border-l-orange-400');
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('.border-l-orange-400');
expect(appointmentCard).toBeInTheDocument();
});
it('should apply shadow on hover for draggable items', () => {
@@ -586,8 +591,9 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const appointmentCard = screen.getByText('John Doe').closest('div');
expect(appointmentCard).toHaveClass('hover:shadow-md');
// Use the specific class selector
const appointmentCard = screen.getByText('John Doe').closest('[class*="hover:shadow-md"]');
expect(appointmentCard).toBeInTheDocument();
});
});
@@ -649,7 +655,8 @@ describe('Sidebar', () => {
{ wrapper: createDndWrapper() }
);
const header = screen.getByText('Resources').parentElement;
// The height style is on the header div itself
const header = screen.getByText('Resources').closest('[style*="height"]');
expect(header).toHaveStyle({ height: '48px' });
});

View File

@@ -17,11 +17,14 @@ import {
Plug,
FileSignature,
CalendarOff,
LayoutTemplate,
MapPin,
} from 'lucide-react';
import { Business, User } from '../types';
import { useLogout } from '../hooks/useAuth';
import { usePlanFeatures } from '../hooks/usePlanFeatures';
import SmoothScheduleLogo from './SmoothScheduleLogo';
import UnfinishedBadge from './ui/UnfinishedBadge';
import {
SidebarSection,
SidebarItem,
@@ -106,7 +109,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{/* Core Features - Always visible */}
<SidebarSection isCollapsed={isCollapsed}>
<SidebarItem
to="/"
to="/dashboard"
icon={LayoutDashboard}
label={t('nav.dashboard')}
isCollapsed={isCollapsed}
@@ -114,7 +117,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
/>
{!isStaff && (
<SidebarItem
to="/scheduler"
to="/dashboard/scheduler"
icon={CalendarDays}
label={t('nav.scheduler')}
isCollapsed={isCollapsed}
@@ -122,16 +125,17 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{!isStaff && (
<SidebarItem
to="/tasks"
to="/dashboard/tasks"
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
badgeElement={<UnfinishedBadge />}
/>
)}
{isStaff && (
<SidebarItem
to="/my-schedule"
to="/dashboard/my-schedule"
icon={CalendarDays}
label={t('nav.mySchedule', 'My Schedule')}
isCollapsed={isCollapsed}
@@ -139,7 +143,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{(role === 'staff' || role === 'resource') && (
<SidebarItem
to="/my-availability"
to="/dashboard/my-availability"
icon={CalendarOff}
label={t('nav.myAvailability', 'My Availability')}
isCollapsed={isCollapsed}
@@ -151,19 +155,27 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewManagementPages && (
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
<SidebarItem
to="/customers"
to="/dashboard/site-editor"
icon={LayoutTemplate}
label={t('nav.siteBuilder', 'Site Builder')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/dashboard/customers"
icon={Users}
label={t('nav.customers')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
<SidebarItem
to="/services"
to="/dashboard/services"
icon={Briefcase}
label={t('nav.services', 'Services')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/resources"
to="/dashboard/resources"
icon={ClipboardList}
label={t('nav.resources')}
isCollapsed={isCollapsed}
@@ -171,25 +183,34 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<>
<SidebarItem
to="/staff"
to="/dashboard/staff"
icon={Users}
label={t('nav.staff')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
{canUse('contracts') && (
<SidebarItem
to="/contracts"
to="/dashboard/contracts"
icon={FileSignature}
label={t('nav.contracts', 'Contracts')}
isCollapsed={isCollapsed}
badgeElement={<UnfinishedBadge />}
/>
)}
<SidebarItem
to="/time-blocks"
to="/dashboard/time-blocks"
icon={CalendarOff}
label={t('nav.timeBlocks', 'Time Blocks')}
isCollapsed={isCollapsed}
/>
<SidebarItem
to="/dashboard/locations"
icon={MapPin}
label={t('nav.locations', 'Locations')}
isCollapsed={isCollapsed}
locked={!canUse('multi_location')}
/>
</>
)}
</SidebarSection>
@@ -200,7 +221,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<SidebarSection title={t('nav.sections.communicate', 'Communicate')} isCollapsed={isCollapsed}>
{canSendMessages && (
<SidebarItem
to="/messages"
to="/dashboard/messages"
icon={MessageSquare}
label={t('nav.messages')}
isCollapsed={isCollapsed}
@@ -208,7 +229,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
)}
{canViewTickets && (
<SidebarItem
to="/tickets"
to="/dashboard/tickets"
icon={Ticket}
label={t('nav.tickets')}
isCollapsed={isCollapsed}
@@ -221,7 +242,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
<SidebarItem
to="/payments"
to="/dashboard/payments"
icon={CreditCard}
label={t('nav.payments')}
isCollapsed={isCollapsed}
@@ -234,11 +255,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins/my-plugins"
to="/dashboard/plugins/my-plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}
locked={!canUse('plugins')}
badgeElement={<UnfinishedBadge />}
/>
</SidebarSection>
)}
@@ -249,14 +271,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
<SidebarSection isCollapsed={isCollapsed}>
{canViewSettings && (
<SidebarItem
to="/settings"
to="/dashboard/settings"
icon={Settings}
label={t('nav.businessSettings')}
isCollapsed={isCollapsed}
/>
)}
<SidebarItem
to="/help"
to="/dashboard/help"
icon={HelpCircle}
label={t('nav.helpDocs', 'Help & Docs')}
isCollapsed={isCollapsed}

View File

@@ -28,7 +28,7 @@ const TrialBanner: React.FC<TrialBannerProps> = ({ business }) => {
const trialEndDate = business.trialEnd ? new Date(business.trialEnd).toLocaleDateString() : '';
const handleUpgrade = () => {
navigate('/upgrade');
navigate('/dashboard/upgrade');
};
const handleDismiss = () => {

View File

@@ -51,7 +51,7 @@ const BannerPrompt: React.FC<{ feature: FeatureKey; showDescription: boolean }>
</p>
)}
<Link
to="/settings/billing"
to="/dashboard/settings/billing"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
>
<Crown className="w-4 h-4" />
@@ -97,7 +97,7 @@ const OverlayPrompt: React.FC<{
{FEATURE_DESCRIPTIONS[feature]}
</p>
<Link
to="/settings/billing"
to="/dashboard/settings/billing"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:from-amber-600 hover:to-orange-600 transition-all shadow-md hover:shadow-lg"
>
<Crown className="w-5 h-5" />

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

View File

@@ -1,429 +1,114 @@
/**
* Unit tests for ConfirmationModal component
*
* Tests all modal functionality including:
* - Rendering with different props (title, message, variants)
* - User interactions (confirm, cancel, close button)
* - Custom button labels
* - Loading states
* - Modal visibility (isOpen true/false)
* - Different modal variants (info, warning, danger, success)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import ConfirmationModal from '../ConfirmationModal';
// Setup i18n for tests
beforeEach(() => {
i18n.init({
lng: 'en',
fallbackLng: 'en',
resources: {
en: {
translation: {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
},
},
},
},
interpolation: {
escapeValue: false,
},
});
});
// Test wrapper with i18n provider
const renderWithI18n = (component: React.ReactElement) => {
return render(<I18nextProvider i18n={i18n}>{component}</I18nextProvider>);
};
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('ConfirmationModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onConfirm: vi.fn(),
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
title: 'Test Title',
message: 'Test message',
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render modal with title and message', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('returns null when not open', () => {
const { container } = render(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
expect(container.firstChild).toBeNull();
});
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('renders title when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render modal with React node as message', () => {
const messageNode = (
<div>
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
);
it('renders message when open', () => {
render(<ConfirmationModal {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
renderWithI18n(<ConfirmationModal {...defaultProps} message={messageNode} />);
it('renders message as ReactNode', () => {
render(
<ConfirmationModal
{...defaultProps}
message={<span data-testid="custom-message">Custom content</span>}
/>
);
expect(screen.getByTestId('custom-message')).toBeInTheDocument();
});
expect(screen.getByText('First paragraph')).toBeInTheDocument();
expect(screen.getByText('Second paragraph')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
fireEvent.click(buttons[0]);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('should not render when isOpen is false', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} isOpen={false} />
);
it('calls onClose when cancel button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
expect(container).toBeEmptyDOMElement();
});
it('calls onConfirm when confirm button is clicked', () => {
render(<ConfirmationModal {...defaultProps} />);
fireEvent.click(screen.getByText('common.confirm'));
expect(defaultProps.onConfirm).toHaveBeenCalled();
});
it('should render default confirm and cancel buttons', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('uses custom confirm text', () => {
render(<ConfirmationModal {...defaultProps} confirmText="Yes, delete" />);
expect(screen.getByText('Yes, delete')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('uses custom cancel text', () => {
render(<ConfirmationModal {...defaultProps} cancelText="No, keep" />);
expect(screen.getByText('No, keep')).toBeInTheDocument();
});
it('should render custom button labels', () => {
renderWithI18n(
<ConfirmationModal
{...defaultProps}
confirmText="Yes, delete it"
cancelText="No, keep it"
/>
);
it('renders info variant', () => {
render(<ConfirmationModal {...defaultProps} variant="info" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument();
});
it('renders warning variant', () => {
render(<ConfirmationModal {...defaultProps} variant="warning" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('should render close button in header', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
it('renders danger variant', () => {
render(<ConfirmationModal {...defaultProps} variant="danger" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
// Close button is an SVG icon, so we find it by its parent button
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find((button) =>
button.querySelector('svg') && button !== screen.getByRole('button', { name: /confirm/i })
);
it('renders success variant', () => {
render(<ConfirmationModal {...defaultProps} variant="success" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
expect(closeButton).toBeInTheDocument();
it('disables buttons when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toBeDisabled();
});
});
describe('User Interactions', () => {
it('should call onConfirm when confirm button is clicked', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when close button is clicked', () => {
const onClose = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onClose={onClose} />);
// Find the close button (X icon in header)
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((button) =>
button.querySelector('svg') && !button.textContent?.includes('Confirm')
);
if (closeButton) {
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
}
});
it('should not call onConfirm multiple times on multiple clicks', () => {
const onConfirm = vi.fn();
renderWithI18n(<ConfirmationModal {...defaultProps} onConfirm={onConfirm} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(3);
});
});
describe('Loading State', () => {
it('should show loading spinner when isLoading is true', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
const spinner = confirmButton.querySelector('svg.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('should disable confirm button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toBeDisabled();
});
it('should disable cancel button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(cancelButton).toBeDisabled();
});
it('should disable close button when loading', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} isLoading={true} />);
const buttons = screen.getAllByRole('button');
const closeButton = buttons.find((button) =>
button.querySelector('svg') && !button.textContent?.includes('Confirm')
);
expect(closeButton).toBeDisabled();
});
it('should not call onConfirm when button is disabled due to loading', () => {
const onConfirm = vi.fn();
renderWithI18n(
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
fireEvent.click(confirmButton);
// Button is disabled, so onClick should not fire
expect(onConfirm).not.toHaveBeenCalled();
});
});
describe('Modal Variants', () => {
it('should render info variant by default', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
// Info variant has blue styling
const iconContainer = container.querySelector('.bg-blue-100');
expect(iconContainer).toBeInTheDocument();
});
it('should render info variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="info" />
);
const iconContainer = container.querySelector('.bg-blue-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-blue-600');
});
it('should render warning variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="warning" />
);
const iconContainer = container.querySelector('.bg-amber-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-amber-600');
});
it('should render danger variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="danger" />
);
const iconContainer = container.querySelector('.bg-red-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-red-600');
});
it('should render success variant with correct styling', () => {
const { container } = renderWithI18n(
<ConfirmationModal {...defaultProps} variant="success" />
);
const iconContainer = container.querySelector('.bg-green-100');
expect(iconContainer).toBeInTheDocument();
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toHaveClass('bg-green-600');
});
});
describe('Accessibility', () => {
it('should have proper button roles', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2); // At least confirm and cancel
});
it('should have backdrop overlay', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/50');
expect(backdrop).toBeInTheDocument();
});
it('should have modal content container', () => {
const { container } = renderWithI18n(<ConfirmationModal {...defaultProps} />);
const modal = container.querySelector('.bg-white.dark\\:bg-gray-800.rounded-xl');
expect(modal).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty title', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} title="" />);
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).toBeInTheDocument();
});
it('should handle empty message', () => {
renderWithI18n(<ConfirmationModal {...defaultProps} message="" />);
const title = screen.getByText('Confirm Action');
expect(title).toBeInTheDocument();
});
it('should handle very long title', () => {
const longTitle = 'A'.repeat(200);
renderWithI18n(<ConfirmationModal {...defaultProps} title={longTitle} />);
expect(screen.getByText(longTitle)).toBeInTheDocument();
});
it('should handle very long message', () => {
const longMessage = 'B'.repeat(500);
renderWithI18n(<ConfirmationModal {...defaultProps} message={longMessage} />);
expect(screen.getByText(longMessage)).toBeInTheDocument();
});
it('should handle rapid open/close state changes', () => {
const { rerender } = renderWithI18n(<ConfirmationModal {...defaultProps} isOpen={true} />);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} isOpen={false} />
</I18nextProvider>
);
expect(screen.queryByText('Confirm Action')).not.toBeInTheDocument();
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} isOpen={true} />
</I18nextProvider>
);
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
});
});
describe('Complete User Flows', () => {
it('should support complete confirmation flow', () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
renderWithI18n(
<ConfirmationModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
title="Delete Item"
message="Are you sure you want to delete this item?"
variant="danger"
confirmText="Delete"
cancelText="Cancel"
/>
);
// User sees the modal
expect(screen.getByText('Delete Item')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to delete this item?')).toBeInTheDocument();
// User clicks confirm
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it('should support complete cancellation flow', () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
renderWithI18n(
<ConfirmationModal
{...defaultProps}
onConfirm={onConfirm}
onClose={onClose}
variant="warning"
/>
);
// User sees the modal
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
// User clicks cancel
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it('should support loading state during async operation', () => {
const onConfirm = vi.fn();
const { rerender } = renderWithI18n(
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={false} />
);
// Initial state - buttons enabled
const confirmButton = screen.getByRole('button', { name: /confirm/i });
expect(confirmButton).not.toBeDisabled();
// User clicks confirm
fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledTimes(1);
// Parent component sets loading state
rerender(
<I18nextProvider i18n={i18n}>
<ConfirmationModal {...defaultProps} onConfirm={onConfirm} isLoading={true} />
</I18nextProvider>
);
// Buttons now disabled during async operation
expect(screen.getByRole('button', { name: /confirm/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
});
it('shows spinner when loading', () => {
render(<ConfirmationModal {...defaultProps} isLoading={true} />);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});

View File

@@ -1,752 +1,83 @@
/**
* Unit tests for EmailTemplateSelector component
*
* Tests cover:
* - Rendering with templates list
* - Template selection and onChange callback
* - Selected template display (active state)
* - Empty templates array handling
* - Loading states
* - Disabled state
* - Category filtering
* - Template info display
* - Edit link functionality
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React, { type ReactNode } from 'react';
import EmailTemplateSelector from '../EmailTemplateSelector';
import apiClient from '../../api/client';
import { EmailTemplate } from '../../types';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback,
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Test data factories
const createMockEmailTemplate = (overrides?: Partial<EmailTemplate>): EmailTemplate => ({
id: '1',
name: 'Test Template',
description: 'Test description',
subject: 'Test Subject',
htmlContent: '<p>Test content</p>',
textContent: 'Test content',
scope: 'BUSINESS',
isDefault: false,
category: 'APPOINTMENT',
...overrides,
});
// Mock API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: [] })),
},
}));
// Test wrapper with QueryClient
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('EmailTemplateSelector', () => {
let queryClient: QueryClient;
const mockOnChange = vi.fn();
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
vi.clearAllMocks();
it('renders select element', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
afterEach(() => {
queryClient.clear();
it('shows placeholder text after loading', async () => {
render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
placeholder="Select a template"
/>,
{ wrapper: createWrapper() }
);
// Wait for loading to finish and placeholder to appear
await screen.findByText('Select a template');
});
describe('Rendering with templates', () => {
it('should render with templates list', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Welcome Email' }),
createMockEmailTemplate({ id: '2', name: 'Confirmation Email', category: 'CONFIRMATION' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options).toHaveLength(3); // placeholder + 2 templates
expect(options[1]).toHaveTextContent('Welcome Email (APPOINTMENT)');
expect(options[2]).toHaveTextContent('Confirmation Email (CONFIRMATION)');
});
it('should render templates without category suffix for OTHER category', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Custom Email', category: 'OTHER' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
const options = Array.from(select.options);
expect(options[1]).toHaveTextContent('Custom Email');
expect(options[1]).not.toHaveTextContent('(OTHER)');
});
it('should convert numeric IDs to strings', async () => {
const mockData = [
{
id: 123,
name: 'Numeric ID Template',
description: 'Test',
category: 'REMINDER',
scope: 'BUSINESS',
updated_at: '2025-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockData });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[1].value).toBe('123');
});
it('is disabled when disabled prop is true', () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
describe('Template selection', () => {
it('should select template on click', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
createMockEmailTemplate({ id: '2', name: 'Template 2' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '2' } });
expect(mockOnChange).toHaveBeenCalledWith('2');
});
it('should call onChange with undefined when selecting empty option', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
fireEvent.change(select, { target: { value: '' } });
expect(mockOnChange).toHaveBeenCalledWith(undefined);
});
it('should handle numeric value prop', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={1} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
it('applies custom className', () => {
const { container } = render(
<EmailTemplateSelector
value={undefined}
onChange={() => {}}
className="custom-class"
/>,
{ wrapper: createWrapper() }
);
expect(container.firstChild).toHaveClass('custom-class');
});
describe('Selected template display', () => {
it('should show selected template as active', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Selected Template',
description: 'This template is selected',
}),
createMockEmailTemplate({ id: '2', name: 'Other Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('1');
});
it('should display selected template info with description', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'Template Name',
description: 'Template description text',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('Template description text')).toBeInTheDocument();
});
});
it('should display template name when description is empty', async () => {
const mockTemplates = [
createMockEmailTemplate({
id: '1',
name: 'No Description Template',
description: '',
}),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText('No Description Template')).toBeInTheDocument();
});
});
it('should display edit link for selected template', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Editable Template' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const editLink = screen.getByRole('link', { name: /edit/i });
expect(editLink).toBeInTheDocument();
expect(editLink).toHaveAttribute('href', '#/email-templates');
expect(editLink).toHaveAttribute('target', '_blank');
expect(editLink).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('should not display template info when no template is selected', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
const editLink = screen.queryByRole('link', { name: /edit/i });
expect(editLink).not.toBeInTheDocument();
});
});
describe('Empty templates array', () => {
it('should handle empty templates array', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByText(/no email templates yet/i)).toBeInTheDocument();
});
});
it('should display create link when templates array is empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const createLink = screen.getByRole('link', { name: /create your first template/i });
expect(createLink).toBeInTheDocument();
expect(createLink).toHaveAttribute('href', '#/email-templates');
});
});
it('should render select with only placeholder option when empty', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options).toHaveLength(1); // only placeholder
});
});
});
describe('Loading states', () => {
it('should show loading text in placeholder when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves to keep loading state
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Loading...');
});
it('should disable select when loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
it('should not show empty state while loading', () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
const emptyMessage = screen.queryByText(/no email templates yet/i);
expect(emptyMessage).not.toBeInTheDocument();
});
});
describe('Disabled state', () => {
it('should disable select when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
});
it('should apply disabled attribute when disabled prop is true', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} disabled={true} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox');
expect(select).toBeDisabled();
});
// Verify the select element has disabled attribute
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select).toHaveAttribute('disabled');
});
});
describe('Category filtering', () => {
it('should fetch templates with category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
});
it('should fetch templates without category filter when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?');
});
});
it('should refetch when category changes', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { rerender } = render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="REMINDER" />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=REMINDER');
});
vi.clearAllMocks();
rerender(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} category="CONFIRMATION" />
);
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/email-templates/?category=CONFIRMATION');
});
});
});
describe('Props and customization', () => {
it('should use custom placeholder when provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
placeholder="Choose an email template"
/>,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Choose an email template');
});
});
it('should use default placeholder when not provided', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.options[0]).toHaveTextContent('Select a template...');
});
});
it('should apply custom className', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector
value={undefined}
onChange={mockOnChange}
className="custom-class"
/>,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const container = screen.getByRole('combobox').parentElement?.parentElement;
expect(container).toHaveClass('custom-class');
});
});
it('should work without className prop', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
});
describe('Icons', () => {
it('should display Mail icon', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] });
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const container = screen.getByRole('combobox').parentElement;
const svg = container?.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
it('should display ExternalLink icon for selected template', async () => {
const mockTemplates = [
createMockEmailTemplate({ id: '1', name: 'Template 1' }),
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: mockTemplates.map(t => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
scope: t.scope,
updated_at: '2025-01-01T00:00:00Z',
})),
});
render(
<EmailTemplateSelector value="1" onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => {
const editLink = screen.getByRole('link', { name: /edit/i });
const svg = editLink.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});
});
describe('API error handling', () => {
it('should handle API errors gracefully', async () => {
const error = new Error('API Error');
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
render(
<EmailTemplateSelector value={undefined} onChange={mockOnChange} />,
{ wrapper: createWrapper(queryClient) }
);
// Component should still render the select
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('shows empty state message when no templates', async () => {
render(
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
{ wrapper: createWrapper() }
);
// Wait for loading to finish
await screen.findByText('No email templates yet.');
});
});

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

View File

@@ -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');
});
});

View File

@@ -1,264 +1,57 @@
/**
* Unit tests for HelpButton component
*
* Tests cover:
* - Component rendering
* - Link navigation
* - Icon display
* - Text display and responsive behavior
* - Accessibility attributes
* - Custom className prop
* - Internationalization (i18n)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import HelpButton from '../HelpButton';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback,
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Test wrapper with Router
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
};
describe('HelpButton', () => {
beforeEach(() => {
vi.clearAllMocks();
const renderHelpButton = (props: { helpPath: string; className?: string }) => {
return render(
<BrowserRouter>
<HelpButton {...props} />
</BrowserRouter>
);
};
it('renders help link', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
describe('Rendering', () => {
it('should render the button', () => {
render(<HelpButton helpPath="/help/getting-started" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
it('should render as a Link component with correct href', () => {
render(<HelpButton helpPath="/help/resources" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/resources');
});
it('should render with different help paths', () => {
const { rerender } = render(<HelpButton helpPath="/help/page1" />, {
wrapper: createWrapper(),
});
let link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page1');
rerender(<HelpButton helpPath="/help/page2" />);
link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/page2');
});
it('has correct href', () => {
renderHelpButton({ helpPath: '/help/dashboard' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/help/dashboard');
});
describe('Icon Display', () => {
it('should display the HelpCircle icon', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
// Check for SVG icon (lucide-react renders as SVG)
const svg = link.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('renders help text', () => {
renderHelpButton({ helpPath: '/help/test' });
expect(screen.getByText('Help')).toBeInTheDocument();
});
describe('Text Display', () => {
it('should display help text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should apply responsive class to hide text on small screens', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const text = screen.getByText('Help');
expect(text).toHaveClass('hidden', 'sm:inline');
});
it('has title attribute', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
describe('Accessibility', () => {
it('should have title attribute', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
it('should be keyboard accessible as a link', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link.tagName).toBe('A');
});
it('should have accessible name from text content', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link', { name: /help/i });
expect(link).toBeInTheDocument();
});
it('applies custom className', () => {
renderHelpButton({ helpPath: '/help/test', className: 'custom-class' });
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
describe('Styling', () => {
it('should apply default classes', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
expect(link).toHaveClass('gap-1.5');
expect(link).toHaveClass('px-3');
expect(link).toHaveClass('py-1.5');
expect(link).toHaveClass('text-sm');
expect(link).toHaveClass('rounded-lg');
expect(link).toHaveClass('transition-colors');
});
it('should apply color classes for light mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('text-gray-500');
expect(link).toHaveClass('hover:text-brand-600');
expect(link).toHaveClass('hover:bg-gray-100');
});
it('should apply color classes for dark mode', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('dark:text-gray-400');
expect(link).toHaveClass('dark:hover:text-brand-400');
expect(link).toHaveClass('dark:hover:bg-gray-800');
});
it('should apply custom className when provided', () => {
render(<HelpButton helpPath="/help" className="custom-class" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
});
it('should merge custom className with default classes', () => {
render(<HelpButton helpPath="/help" className="ml-auto" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveClass('ml-auto');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
it('should work without custom className', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
});
});
describe('Internationalization', () => {
it('should use translation for help text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
// The mock returns the fallback value
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should use translation for title attribute', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('title', 'Help');
});
});
describe('Integration', () => {
it('should render correctly with all props together', () => {
render(
<HelpButton
helpPath="/help/advanced"
className="custom-styling"
/>,
{ wrapper: createWrapper() }
);
const link = screen.getByRole('link');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/help/advanced');
expect(link).toHaveAttribute('title', 'Help');
expect(link).toHaveClass('custom-styling');
expect(link).toHaveClass('inline-flex');
const icon = link.querySelector('svg');
expect(icon).toBeInTheDocument();
const text = screen.getByText('Help');
expect(text).toBeInTheDocument();
});
it('should maintain structure with icon and text', () => {
render(<HelpButton helpPath="/help" />, {
wrapper: createWrapper(),
});
const link = screen.getByRole('link');
const svg = link.querySelector('svg');
const span = link.querySelector('span');
expect(svg).toBeInTheDocument();
expect(span).toBeInTheDocument();
expect(span).toHaveTextContent('Help');
});
it('has default styles', () => {
renderHelpButton({ helpPath: '/help/test' });
const link = screen.getByRole('link');
expect(link).toHaveClass('inline-flex');
expect(link).toHaveClass('items-center');
});
});

View File

@@ -1,560 +1,93 @@
/**
* Unit tests for LanguageSelector component
*
* Tests cover:
* - Rendering both dropdown and inline variants
* - Current language display
* - Dropdown open/close functionality
* - Language selection and change
* - Available languages display
* - Flag display
* - Click outside to close dropdown
* - Accessibility attributes
* - Responsive text hiding
* - Custom className prop
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import LanguageSelector from '../LanguageSelector';
// Mock i18n
const mockChangeLanguage = vi.fn();
const mockCurrentLanguage = 'en';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
language: mockCurrentLanguage,
changeLanguage: mockChangeLanguage,
language: 'en',
changeLanguage: vi.fn(),
},
}),
}));
// Mock i18n module with supported languages
// Mock i18n module
vi.mock('../../i18n', () => ({
supportedLanguages: [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
],
}));
describe('LanguageSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Dropdown Variant (Default)', () => {
describe('Rendering', () => {
it('should render the language selector button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button', { expanded: false });
expect(button).toBeInTheDocument();
});
it('should display current language name on desktop', () => {
render(<LanguageSelector />);
const languageName = screen.getByText('English');
expect(languageName).toBeInTheDocument();
expect(languageName).toHaveClass('hidden', 'sm:inline');
});
it('should display current language flag by default', () => {
render(<LanguageSelector />);
const flag = screen.getByText('🇺🇸');
expect(flag).toBeInTheDocument();
});
it('should display Globe icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const svg = button.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('should display ChevronDown icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
expect(chevron).toBeInTheDocument();
});
it('should not display flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
const flag = screen.queryByText('🇺🇸');
expect(flag).not.toBeInTheDocument();
});
it('should not show dropdown by default', () => {
render(<LanguageSelector />);
const dropdown = screen.queryByRole('listbox');
expect(dropdown).not.toBeInTheDocument();
});
});
describe('Dropdown Open/Close', () => {
it('should open dropdown when button clicked', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox');
expect(dropdown).toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should close dropdown when button clicked again', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
// Open
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Close
fireEvent.click(button);
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('should rotate chevron icon when dropdown is open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
const chevron = button.querySelector('svg.w-4.h-4.transition-transform');
// Initially not rotated
expect(chevron).not.toHaveClass('rotate-180');
// Open dropdown
fireEvent.click(button);
expect(chevron).toHaveClass('rotate-180');
});
it('should close dropdown when clicking outside', async () => {
render(
<div>
<LanguageSelector />
<button>Outside Button</button>
</div>
);
const button = screen.getByRole('button', { expanded: false });
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
// Click outside
const outsideButton = screen.getByText('Outside Button');
fireEvent.mouseDown(outsideButton);
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
it('should not close dropdown when clicking inside dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox');
fireEvent.mouseDown(dropdown);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
describe('Language Selection', () => {
it('should display all available languages in dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getAllByText('English')).toHaveLength(2); // One in button, one in dropdown
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
});
it('should display flags for all languages in dropdown', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getAllByText('🇺🇸')).toHaveLength(2); // One in button, one in dropdown
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
});
it('should mark current language with Check icon', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
expect(englishOption).toHaveAttribute('aria-selected', 'true');
// Check icon should be present
const checkIcon = englishOption?.querySelector('svg.w-4.h-4');
expect(checkIcon).toBeInTheDocument();
});
it('should change language when option clicked', async () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const spanishOption = screen.getAllByRole('option').find(
opt => opt.textContent?.includes('Español')
);
fireEvent.click(spanishOption!);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('es');
});
});
it('should close dropdown after language selection', async () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const frenchOption = screen.getAllByRole('option').find(
opt => opt.textContent?.includes('Français')
);
fireEvent.click(frenchOption!);
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
it('should highlight selected language with brand color', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
expect(englishOption).toHaveClass('bg-brand-50', 'dark:bg-brand-900/20');
expect(englishOption).toHaveClass('text-brand-700', 'dark:text-brand-300');
});
it('should not highlight non-selected languages with brand color', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
expect(spanishOption).toHaveClass('text-gray-700', 'dark:text-gray-300');
expect(spanishOption).not.toHaveClass('bg-brand-50');
});
});
describe('Accessibility', () => {
it('should have proper ARIA attributes on button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
expect(button).toHaveAttribute('aria-haspopup', 'listbox');
});
it('should update aria-expanded when dropdown opens', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should have aria-label on listbox', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const listbox = screen.getByRole('listbox');
expect(listbox).toHaveAttribute('aria-label', 'Select language');
});
it('should mark language options as selected correctly', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const options = screen.getAllByRole('option');
const englishOption = options.find(opt => opt.textContent?.includes('English'));
const spanishOption = options.find(opt => opt.textContent?.includes('Español'));
expect(englishOption).toHaveAttribute('aria-selected', 'true');
expect(spanishOption).toHaveAttribute('aria-selected', 'false');
});
});
describe('Styling', () => {
it('should apply default classes to button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveClass('flex', 'items-center', 'gap-2');
expect(button).toHaveClass('px-3', 'py-2');
expect(button).toHaveClass('rounded-lg');
expect(button).toHaveClass('transition-colors');
});
it('should apply custom className when provided', () => {
render(<LanguageSelector className="custom-class" />);
const container = screen.getByRole('button').parentElement;
expect(container).toHaveClass('custom-class');
});
it('should apply dropdown animation classes', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
const dropdown = screen.getByRole('listbox').parentElement;
expect(dropdown).toHaveClass('animate-in', 'fade-in', 'slide-in-from-top-2');
});
it('should apply focus ring on button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toHaveClass('focus:outline-none', 'focus:ring-2', 'focus:ring-brand-500');
});
});
});
describe('Inline Variant', () => {
describe('Rendering', () => {
it('should render inline variant when specified', () => {
render(<LanguageSelector variant="inline" />);
// Should show buttons, not a dropdown
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(4); // One for each language
});
it('should display all languages as separate buttons', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
});
it('should display flags in inline variant by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
expect(screen.getByText('🇪🇸')).toBeInTheDocument();
expect(screen.getByText('🇫🇷')).toBeInTheDocument();
expect(screen.getByText('🇩🇪')).toBeInTheDocument();
});
it('should not display flags when showFlag is false', () => {
render(<LanguageSelector variant="inline" showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
expect(screen.queryByText('🇪🇸')).not.toBeInTheDocument();
});
it('should highlight current language button', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByRole('button', { name: /English/i });
expect(englishButton).toHaveClass('bg-brand-600', 'text-white');
});
it('should not highlight non-selected language buttons', () => {
render(<LanguageSelector variant="inline" />);
const spanishButton = screen.getByRole('button', { name: /Español/i });
expect(spanishButton).toHaveClass('bg-gray-100', 'text-gray-700');
expect(spanishButton).not.toHaveClass('bg-brand-600');
});
});
describe('Language Selection', () => {
it('should change language when button clicked', async () => {
render(<LanguageSelector variant="inline" />);
const frenchButton = screen.getByRole('button', { name: /Français/i });
fireEvent.click(frenchButton);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('fr');
});
});
it('should change language for each available language', async () => {
render(<LanguageSelector variant="inline" />);
const germanButton = screen.getByRole('button', { name: /Deutsch/i });
fireEvent.click(germanButton);
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('de');
});
});
});
describe('Styling', () => {
it('should apply flex layout classes', () => {
const { container } = render(<LanguageSelector variant="inline" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('flex', 'flex-wrap', 'gap-2');
});
it('should apply custom className when provided', () => {
const { container } = render(<LanguageSelector variant="inline" className="my-custom-class" />);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('my-custom-class');
});
it('should apply button styling classes', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
buttons.forEach(button => {
expect(button).toHaveClass('px-3', 'py-1.5', 'rounded-lg', 'text-sm', 'font-medium', 'transition-colors');
});
});
it('should apply hover classes to non-selected buttons', () => {
render(<LanguageSelector variant="inline" />);
const spanishButton = screen.getByRole('button', { name: /Español/i });
expect(spanishButton).toHaveClass('hover:bg-gray-200', 'dark:hover:bg-gray-600');
});
});
});
describe('Integration', () => {
it('should render correctly with all dropdown props together', () => {
render(
<LanguageSelector
variant="dropdown"
showFlag={true}
className="custom-class"
/>
);
describe('dropdown variant', () => {
it('renders dropdown button', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
const container = button.parentElement;
expect(container).toHaveClass('custom-class');
});
it('should render correctly with all inline props together', () => {
const { container } = render(
<LanguageSelector
variant="inline"
showFlag={true}
className="inline-custom"
/>
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('inline-custom');
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(4);
it('shows current language flag by default', () => {
render(<LanguageSelector />);
expect(screen.getByText('🇺🇸')).toBeInTheDocument();
});
it('shows current language name on larger screens', () => {
render(<LanguageSelector />);
expect(screen.getByText('English')).toBeInTheDocument();
});
it('should maintain dropdown functionality across re-renders', () => {
const { rerender } = render(<LanguageSelector />);
it('opens dropdown on click', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
rerender(<LanguageSelector className="updated" />);
expect(screen.getByRole('listbox')).toBeInTheDocument();
it('shows all languages when open', () => {
render(<LanguageSelector />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(screen.getByText('Español')).toBeInTheDocument();
expect(screen.getByText('Français')).toBeInTheDocument();
});
it('hides flag when showFlag is false', () => {
render(<LanguageSelector showFlag={false} />);
expect(screen.queryByText('🇺🇸')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<LanguageSelector className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Edge Cases', () => {
it('should handle missing language gracefully', () => {
// The component should fall back to the first language if current language is not found
render(<LanguageSelector />);
// Should still render without crashing
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should cleanup event listener on unmount', () => {
const { unmount } = render(<LanguageSelector />);
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
});
it('should not call changeLanguage when clicking current language', async () => {
describe('inline variant', () => {
it('renders all language buttons', () => {
render(<LanguageSelector variant="inline" />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBe(3);
});
const englishButton = screen.getByRole('button', { name: /English/i });
fireEvent.click(englishButton);
it('renders language names', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/English/)).toBeInTheDocument();
expect(screen.getByText(/Español/)).toBeInTheDocument();
expect(screen.getByText(/Français/)).toBeInTheDocument();
});
await waitFor(() => {
expect(mockChangeLanguage).toHaveBeenCalledWith('en');
});
it('highlights current language', () => {
render(<LanguageSelector variant="inline" />);
const englishButton = screen.getByText(/English/).closest('button');
expect(englishButton).toHaveClass('bg-brand-600');
});
// Even if clicking the current language, it still calls changeLanguage
// This is expected behavior (idempotent)
it('shows flags by default', () => {
render(<LanguageSelector variant="inline" />);
expect(screen.getByText(/🇺🇸/)).toBeInTheDocument();
});
});
});

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

View File

@@ -1,534 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import MasqueradeBanner from '../MasqueradeBanner';
import { User } from '../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: any) => {
const translations: Record<string, string> = {
'platform.masquerade.masqueradingAs': 'Masquerading as',
'platform.masquerade.loggedInAs': `Logged in as ${options?.name || ''}`,
'platform.masquerade.returnTo': `Return to ${options?.name || ''}`,
'platform.masquerade.stopMasquerading': 'Stop Masquerading',
};
return translations[key] || key;
t: (key: string, options?: { name?: string }) => {
if (options?.name) return `${key} ${options.name}`;
return key;
},
}),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Eye: ({ size }: { size: number }) => <svg data-testid="eye-icon" width={size} height={size} />,
XCircle: ({ size }: { size: number }) => <svg data-testid="xcircle-icon" width={size} height={size} />,
}));
describe('MasqueradeBanner', () => {
const mockOnStop = vi.fn();
const effectiveUser: User = {
id: '2',
name: 'John Doe',
email: 'john@example.com',
role: 'owner',
};
const originalUser: User = {
id: '1',
name: 'Admin User',
email: 'admin@platform.com',
role: 'superuser',
};
const previousUser: User = {
id: '3',
name: 'Manager User',
email: 'manager@example.com',
role: 'platform_manager',
const defaultProps = {
effectiveUser: { id: '1', name: 'John Doe', email: 'john@test.com', role: 'staff' as const },
originalUser: { id: '2', name: 'Admin User', email: 'admin@test.com', role: 'superuser' as const },
previousUser: null,
onStop: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the banner with correct structure', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
// Check for main container - it's the first child div
const banner = container.firstChild as HTMLElement;
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('bg-orange-600', 'text-white');
});
it('displays the Eye icon', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const eyeIcon = screen.getByTestId('eye-icon');
expect(eyeIcon).toBeInTheDocument();
expect(eyeIcon).toHaveAttribute('width', '18');
expect(eyeIcon).toHaveAttribute('height', '18');
});
it('displays the XCircle icon in the button', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const xCircleIcon = screen.getByTestId('xcircle-icon');
expect(xCircleIcon).toBeInTheDocument();
expect(xCircleIcon).toHaveAttribute('width', '14');
expect(xCircleIcon).toHaveAttribute('height', '14');
});
it('renders effective user name', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
describe('User Information Display', () => {
it('displays the effective user name and role', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/owner/i)).toBeInTheDocument();
});
it('displays the original user name', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
});
it('displays masquerading as message', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
});
it('displays different user roles correctly', () => {
const staffUser: User = {
id: '4',
name: 'Staff Member',
email: 'staff@example.com',
role: 'staff',
};
render(
<MasqueradeBanner
effectiveUser={staffUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Staff Member')).toBeInTheDocument();
// Use a more specific query to avoid matching "Staff Member" text
expect(screen.getByText(/\(staff\)/i)).toBeInTheDocument();
});
it('renders effective user role', () => {
render(<MasqueradeBanner {...defaultProps} />);
// The role is split across elements: "(" + "staff" + ")"
expect(screen.getByText(/staff/)).toBeInTheDocument();
});
describe('Stop Masquerade Button', () => {
it('renders the stop masquerade button when no previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toBeInTheDocument();
});
it('renders the return to user button when previous user exists', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
expect(button).toBeInTheDocument();
});
it('calls onStop when button is clicked', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(1);
});
it('calls onStop when return button is clicked with previous user', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Return to Manager User/i });
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(1);
});
it('can be clicked multiple times', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);
expect(mockOnStop).toHaveBeenCalledTimes(3);
});
it('renders original user info', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/Admin User/)).toBeInTheDocument();
});
describe('Styling and Visual State', () => {
it('has warning/info styling with orange background', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('bg-orange-600');
expect(banner).toHaveClass('text-white');
});
it('has proper button styling', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button', { name: /Stop Masquerading/i });
expect(button).toHaveClass('bg-white');
expect(button).toHaveClass('text-orange-600');
expect(button).toHaveClass('hover:bg-orange-50');
});
it('has animated pulse effect on Eye icon container', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const eyeIcon = screen.getByTestId('eye-icon');
const iconContainer = eyeIcon.closest('div');
expect(iconContainer).toHaveClass('animate-pulse');
});
it('has proper layout classes for flexbox', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('flex');
expect(banner).toHaveClass('items-center');
expect(banner).toHaveClass('justify-between');
});
it('has z-index for proper stacking', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('z-50');
expect(banner).toHaveClass('relative');
});
it('has shadow for visual prominence', () => {
const { container } = render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const banner = container.firstChild as HTMLElement;
expect(banner).toHaveClass('shadow-md');
});
it('calls onStop when button is clicked', () => {
render(<MasqueradeBanner {...defaultProps} />);
const stopButton = screen.getByRole('button');
fireEvent.click(stopButton);
expect(defaultProps.onStop).toHaveBeenCalled();
});
describe('Edge Cases', () => {
it('handles users with numeric IDs', () => {
const numericIdUser: User = {
id: 123,
name: 'Numeric User',
email: 'numeric@example.com',
role: 'customer',
};
render(
<MasqueradeBanner
effectiveUser={numericIdUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText('Numeric User')).toBeInTheDocument();
});
it('handles users with long names', () => {
const longNameUser: User = {
id: '5',
name: 'This Is A Very Long User Name That Should Still Display Properly',
email: 'longname@example.com',
role: 'manager',
};
render(
<MasqueradeBanner
effectiveUser={longNameUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(
screen.getByText('This Is A Very Long User Name That Should Still Display Properly')
).toBeInTheDocument();
});
it('handles all possible user roles', () => {
const roles: Array<User['role']> = [
'superuser',
'platform_manager',
'platform_support',
'owner',
'manager',
'staff',
'resource',
'customer',
];
roles.forEach((role) => {
const { unmount } = render(
<MasqueradeBanner
effectiveUser={{ ...effectiveUser, role }}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByText(new RegExp(role, 'i'))).toBeInTheDocument();
unmount();
});
});
it('handles previousUser being null', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Stop Masquerading/i })).toBeInTheDocument();
expect(screen.queryByText(/Return to/i)).not.toBeInTheDocument();
});
it('handles previousUser being defined', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={previousUser}
onStop={mockOnStop}
/>
);
expect(screen.getByRole('button', { name: /Return to Manager User/i })).toBeInTheDocument();
expect(screen.queryByText(/Stop Masquerading/i)).not.toBeInTheDocument();
});
it('shows return to previous user text when previousUser exists', () => {
const propsWithPrevious = {
...defaultProps,
previousUser: { id: '3', name: 'Manager', email: 'manager@test.com', role: 'manager' as const },
};
render(<MasqueradeBanner {...propsWithPrevious} />);
expect(screen.getByText(/platform.masquerade.returnTo/)).toBeInTheDocument();
});
describe('Accessibility', () => {
it('has a clickable button element', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button.tagName).toBe('BUTTON');
});
it('button has descriptive text', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const button = screen.getByRole('button');
expect(button).toHaveTextContent(/Stop Masquerading/i);
});
it('displays user information in semantic HTML', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
const strongElement = screen.getByText('John Doe');
expect(strongElement.tagName).toBe('STRONG');
});
it('shows stop masquerading text when no previousUser', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText('platform.masquerade.stopMasquerading')).toBeInTheDocument();
});
describe('Component Integration', () => {
it('renders without crashing with minimal props', () => {
const minimalEffectiveUser: User = {
id: '1',
name: 'Test',
email: 'test@test.com',
role: 'customer',
};
const minimalOriginalUser: User = {
id: '2',
name: 'Admin',
email: 'admin@test.com',
role: 'superuser',
};
expect(() =>
render(
<MasqueradeBanner
effectiveUser={minimalEffectiveUser}
originalUser={minimalOriginalUser}
previousUser={null}
onStop={mockOnStop}
/>
)
).not.toThrow();
});
it('renders all required elements together', () => {
render(
<MasqueradeBanner
effectiveUser={effectiveUser}
originalUser={originalUser}
previousUser={null}
onStop={mockOnStop}
/>
);
// Check all major elements are present
expect(screen.getByTestId('eye-icon')).toBeInTheDocument();
expect(screen.getByTestId('xcircle-icon')).toBeInTheDocument();
expect(screen.getByText(/Masquerading as/i)).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText(/Logged in as Admin User/i)).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('renders with masquerading label', () => {
render(<MasqueradeBanner {...defaultProps} />);
expect(screen.getByText(/platform.masquerade.masqueradingAs/)).toBeInTheDocument();
});
});

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

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

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

View File

@@ -0,0 +1,481 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ResourceCalendar from '../ResourceCalendar';
import { Appointment } from '../../types';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
// Mock Portal component
vi.mock('../Portal', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
// Mock date-fns to control time-based tests
vi.mock('date-fns', async () => {
const actual = await vi.importActual('date-fns');
return {
...actual,
};
});
// Use today's date for appointments so they show up in the calendar
const today = new Date();
today.setHours(10, 0, 0, 0);
const mockAppointments: Appointment[] = [
{
id: '1',
resourceId: 'resource-1',
customerId: 'customer-1',
customerName: 'John Doe',
serviceId: 'service-1',
startTime: new Date(today.getTime()),
durationMinutes: 60,
status: 'SCHEDULED',
notes: 'First appointment',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
{
id: '2',
resourceId: 'resource-1',
customerId: 'customer-2',
customerName: 'Jane Smith',
serviceId: 'service-2',
startTime: new Date(today.getTime() + 4.5 * 60 * 60 * 1000), // 14:30
durationMinutes: 90,
status: 'SCHEDULED',
notes: 'Second appointment',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
{
id: '3',
resourceId: 'resource-2',
customerId: 'customer-3',
customerName: 'Bob Johnson',
serviceId: 'service-1',
startTime: new Date(today.getTime() + 1 * 60 * 60 * 1000), // 11:00
durationMinutes: 45,
status: 'SCHEDULED',
notes: 'Different resource',
depositAmount: null,
depositTransactionId: '',
finalPrice: null,
finalChargeTransactionId: '',
isVariablePricing: false,
remainingBalance: null,
overpaidAmount: null,
},
];
vi.mock('../../hooks/useAppointments', () => ({
useAppointments: vi.fn(),
useUpdateAppointment: vi.fn(),
}));
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('ResourceCalendar', () => {
const mockOnClose = vi.fn();
const mockUpdateMutate = vi.fn();
const defaultProps = {
resourceId: 'resource-1',
resourceName: 'Dr. Smith',
onClose: mockOnClose,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useAppointments).mockReturnValue({
data: mockAppointments,
isLoading: false,
} as any);
vi.mocked(useUpdateAppointment).mockReturnValue({
mutate: mockUpdateMutate,
mutateAsync: vi.fn(),
} as any);
});
describe('Rendering', () => {
it('renders calendar modal', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
});
it('displays close button', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
expect(closeButton).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const closeButtons = screen.getAllByRole('button');
const closeButton = closeButtons.find(btn => btn.querySelector('svg'));
fireEvent.click(closeButton!);
expect(mockOnClose).toHaveBeenCalled();
});
it('displays resource name in title', () => {
render(<ResourceCalendar {...defaultProps} resourceName="Conference Room A" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Conference Room A Calendar')).toBeInTheDocument();
});
});
describe('View modes', () => {
it('renders day view by default', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const dayButton = screen.getByRole('button', { name: /^day$/i });
expect(dayButton).toHaveClass('bg-white');
});
it('switches to week view when week button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const weekButton = screen.getByRole('button', { name: /^week$/i });
fireEvent.click(weekButton);
expect(weekButton).toHaveClass('bg-white');
});
it('switches to month view when month button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const monthButton = screen.getByRole('button', { name: /^month$/i });
fireEvent.click(monthButton);
expect(monthButton).toHaveClass('bg-white');
});
});
describe('Navigation', () => {
it('displays Today button', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /today/i })).toBeInTheDocument();
});
it('displays previous and next navigation buttons', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const navButtons = buttons.filter(btn => btn.querySelector('svg'));
expect(navButtons.length).toBeGreaterThan(2);
});
it('navigates to previous day in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const buttons = screen.getAllByRole('button');
const prevButton = buttons.find(btn => {
const svg = btn.querySelector('svg');
return svg && btn.querySelector('[class*="ChevronLeft"]');
});
// Initial date rendering
const initialText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
const initialDate = initialText.textContent;
if (prevButton) {
fireEvent.click(prevButton);
const newText = screen.getByText(/\w+, \w+ \d+, \d{4}/);
expect(newText.textContent).not.toBe(initialDate);
}
});
it('clicks Today button to reset to current date', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const todayButton = screen.getByRole('button', { name: /today/i });
fireEvent.click(todayButton);
// Should display current date
expect(screen.getByText(/\w+, \w+ \d+, \d{4}/)).toBeInTheDocument();
});
});
describe('Appointments display', () => {
it('displays appointments for the selected resource', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('filters out appointments for other resources', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.queryByText('Bob Johnson')).not.toBeInTheDocument();
});
it('displays appointment customer names', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('displays appointment time and duration', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Check for time format (e.g., "10:00 AM • 60 min")
// Use getAllByText since there might be multiple appointments with same duration
const timeElements = screen.getAllByText(/10:00 AM/);
expect(timeElements.length).toBeGreaterThan(0);
const durationElements = screen.getAllByText(/1h/);
expect(durationElements.length).toBeGreaterThan(0);
});
});
describe('Loading states', () => {
it('displays loading message when loading', () => {
vi.mocked(useAppointments).mockReturnValue({
data: [],
isLoading: true,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('scheduler.loadingAppointments')).toBeInTheDocument();
});
it('displays empty state when no appointments', () => {
vi.mocked(useAppointments).mockReturnValue({
data: [],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('scheduler.noAppointmentsScheduled')).toBeInTheDocument();
});
});
describe('Week view', () => {
it('renders week view when week button is clicked', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
const weekButton = screen.getByRole('button', { name: /^week$/i });
fireEvent.click(weekButton);
// Verify week button is active (has bg-white class)
expect(weekButton).toHaveClass('bg-white');
});
it('week view shows different content than day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Get content in day view
const dayViewContent = document.body.textContent || '';
// Switch to week view
fireEvent.click(screen.getByRole('button', { name: /^week$/i }));
// Get content in week view
const weekViewContent = document.body.textContent || '';
// Week view and day view should have different content
// (Week view shows multiple days, day view shows single day timeline)
expect(weekViewContent).not.toBe(dayViewContent);
// Week view should show hint text for clicking days
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
});
});
describe('Month view', () => {
it('displays calendar grid in month view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Should show weekday headers
expect(screen.getByText('Mon')).toBeInTheDocument();
expect(screen.getByText('Tue')).toBeInTheDocument();
expect(screen.getByText('Wed')).toBeInTheDocument();
});
it('shows appointment count in month view cells', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Should show "2 appts" for the day with 2 appointments
expect(screen.getByText(/2 appt/)).toBeInTheDocument();
});
it('clicking a day in month view switches to week view', async () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /month/i }));
// Find day cells and click one
const dayCells = screen.getAllByText(/^\d+$/);
if (dayCells.length > 0) {
fireEvent.click(dayCells[0].closest('div')!);
await waitFor(() => {
const weekButton = screen.getByRole('button', { name: /week/i });
expect(weekButton).toHaveClass('bg-white');
});
}
});
});
describe('Drag and drop (day view)', () => {
it('displays drag hint in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/drag to move/i)).toBeInTheDocument();
});
it('displays click hint in week/month view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
fireEvent.click(screen.getByRole('button', { name: /week/i }));
expect(screen.getByText(/click a day to view details/i)).toBeInTheDocument();
});
});
describe('Appointment interactions', () => {
it('renders appointments with appropriate styling in day view', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
// Verify appointments are rendered
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
// Verify they have parent elements (appointment containers)
const appointment1 = screen.getByText('John Doe').parentElement;
const appointment2 = screen.getByText('Jane Smith').parentElement;
expect(appointment1).toBeInTheDocument();
expect(appointment2).toBeInTheDocument();
});
});
describe('Duration formatting', () => {
it('formats duration less than 60 minutes as minutes', () => {
const shortAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 45,
};
vi.mocked(useAppointments).mockReturnValue({
data: [shortAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/45 min/)).toBeInTheDocument();
});
it('formats duration 60+ minutes as hours', () => {
const longAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 120,
};
vi.mocked(useAppointments).mockReturnValue({
data: [longAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/2h/)).toBeInTheDocument();
});
it('formats duration with hours and minutes', () => {
const mixedAppointment: Appointment = {
...mockAppointments[0],
durationMinutes: 90,
};
vi.mocked(useAppointments).mockReturnValue({
data: [mixedAppointment],
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText(/1h 30m/)).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('has accessible button labels', () => {
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByRole('button', { name: /^day$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^week$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^month$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^today$/i })).toBeInTheDocument();
});
});
describe('Overlapping appointments', () => {
it('handles overlapping appointments with lane layout', () => {
const todayAt10 = new Date();
todayAt10.setHours(10, 0, 0, 0);
const todayAt1030 = new Date();
todayAt1030.setHours(10, 30, 0, 0);
const overlappingAppointments: Appointment[] = [
{
...mockAppointments[0],
startTime: todayAt10,
durationMinutes: 120,
},
{
...mockAppointments[1],
id: '2',
startTime: todayAt1030,
durationMinutes: 60,
},
];
vi.mocked(useAppointments).mockReturnValue({
data: overlappingAppointments,
isLoading: false,
} as any);
render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
describe('Props variations', () => {
it('works with different resource IDs', () => {
render(<ResourceCalendar {...defaultProps} resourceId="resource-2" />, {
wrapper: createWrapper(),
});
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
it('updates when resource name changes', () => {
const { rerender } = render(<ResourceCalendar {...defaultProps} />, { wrapper: createWrapper() });
expect(screen.getByText('Dr. Smith Calendar')).toBeInTheDocument();
rerender(
<QueryClientProvider client={new QueryClient()}>
<ResourceCalendar {...defaultProps} resourceName="Dr. Jones" />
</QueryClientProvider>
);
expect(screen.getByText('Dr. Jones Calendar')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SandboxBanner from '../SandboxBanner';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('SandboxBanner', () => {
const defaultProps = {
isSandbox: true,
onSwitchToLive: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders when in sandbox mode', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText('TEST MODE')).toBeInTheDocument();
});
it('returns null when not in sandbox mode', () => {
const { container } = render(<SandboxBanner {...defaultProps} isSandbox={false} />);
expect(container.firstChild).toBeNull();
});
it('renders banner description', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText(/You are viewing test data/)).toBeInTheDocument();
});
it('renders switch to live button', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.getByText('Switch to Live')).toBeInTheDocument();
});
it('calls onSwitchToLive when button clicked', () => {
const onSwitchToLive = vi.fn();
render(<SandboxBanner {...defaultProps} onSwitchToLive={onSwitchToLive} />);
fireEvent.click(screen.getByText('Switch to Live'));
expect(onSwitchToLive).toHaveBeenCalled();
});
it('disables button when switching', () => {
render(<SandboxBanner {...defaultProps} isSwitching />);
expect(screen.getByText('Switching...')).toBeDisabled();
});
it('shows switching text when isSwitching is true', () => {
render(<SandboxBanner {...defaultProps} isSwitching />);
expect(screen.getByText('Switching...')).toBeInTheDocument();
});
it('renders dismiss button when onDismiss provided', () => {
render(<SandboxBanner {...defaultProps} onDismiss={() => {}} />);
expect(screen.getByTitle('Dismiss')).toBeInTheDocument();
});
it('does not render dismiss button when onDismiss not provided', () => {
render(<SandboxBanner {...defaultProps} />);
expect(screen.queryByTitle('Dismiss')).not.toBeInTheDocument();
});
it('calls onDismiss when dismiss button clicked', () => {
const onDismiss = vi.fn();
render(<SandboxBanner {...defaultProps} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTitle('Dismiss'));
expect(onDismiss).toHaveBeenCalled();
});
it('has gradient background', () => {
const { container } = render(<SandboxBanner {...defaultProps} />);
expect(container.firstChild).toHaveClass('bg-gradient-to-r');
});
it('renders flask icon', () => {
const { container } = render(<SandboxBanner {...defaultProps} />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import SandboxToggle from '../SandboxToggle';
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
}),
}));
describe('SandboxToggle', () => {
const defaultProps = {
isSandbox: false,
sandboxEnabled: true,
onToggle: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders when sandbox is enabled', () => {
render(<SandboxToggle {...defaultProps} />);
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('returns null when sandbox not enabled', () => {
const { container } = render(<SandboxToggle {...defaultProps} sandboxEnabled={false} />);
expect(container.firstChild).toBeNull();
});
it('highlights Live button when not in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveClass('bg-green-600');
});
it('highlights Test button when in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toHaveClass('bg-orange-500');
});
it('calls onToggle with false when Live clicked', () => {
const onToggle = vi.fn();
render(<SandboxToggle {...defaultProps} isSandbox={true} onToggle={onToggle} />);
fireEvent.click(screen.getByText('Live'));
expect(onToggle).toHaveBeenCalledWith(false);
});
it('calls onToggle with true when Test clicked', () => {
const onToggle = vi.fn();
render(<SandboxToggle {...defaultProps} isSandbox={false} onToggle={onToggle} />);
fireEvent.click(screen.getByText('Test'));
expect(onToggle).toHaveBeenCalledWith(true);
});
it('disables Live button when already in live mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={false} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toBeDisabled();
});
it('disables Test button when already in sandbox mode', () => {
render(<SandboxToggle {...defaultProps} isSandbox={true} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toBeDisabled();
});
it('disables both buttons when toggling', () => {
render(<SandboxToggle {...defaultProps} isToggling />);
const liveButton = screen.getByText('Live').closest('button');
const testButton = screen.getByText('Test').closest('button');
expect(liveButton).toBeDisabled();
expect(testButton).toBeDisabled();
});
it('applies opacity when toggling', () => {
render(<SandboxToggle {...defaultProps} isToggling />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveClass('opacity-50');
});
it('applies custom className', () => {
const { container } = render(<SandboxToggle {...defaultProps} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
it('has title for Live button', () => {
render(<SandboxToggle {...defaultProps} />);
const liveButton = screen.getByText('Live').closest('button');
expect(liveButton).toHaveAttribute('title', 'Live Mode - Production data');
});
it('has title for Test button', () => {
render(<SandboxToggle {...defaultProps} />);
const testButton = screen.getByText('Test').closest('button');
expect(testButton).toHaveAttribute('title', 'Test Mode - Sandbox data');
});
it('renders icons', () => {
const { container } = render(<SandboxToggle {...defaultProps} />);
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBe(2); // Zap and FlaskConical icons
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import SmoothScheduleLogo from '../SmoothScheduleLogo';
describe('SmoothScheduleLogo', () => {
it('renders an SVG element', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('has correct viewBox', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('viewBox', '0 0 1730 1100');
});
it('uses currentColor for fill', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('fill', 'currentColor');
});
it('applies custom className', () => {
const { container } = render(<SmoothScheduleLogo className="custom-logo-class" />);
const svg = container.querySelector('svg');
expect(svg).toHaveClass('custom-logo-class');
});
it('renders without className when not provided', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
it('contains path elements', () => {
const { container } = render(<SmoothScheduleLogo />);
const paths = container.querySelectorAll('path');
expect(paths.length).toBeGreaterThan(0);
});
it('has xmlns attribute', () => {
const { container } = render(<SmoothScheduleLogo />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('xmlns', 'http://www.w3.org/2000/svg');
});
});

View File

@@ -0,0 +1,716 @@
/**
* Unit tests for TopBar component
*
* Tests the top navigation bar that appears at the top of the application.
* Covers:
* - Rendering of all UI elements (search, theme toggle, notifications, etc.)
* - Menu button for mobile view
* - Theme toggle functionality
* - User profile dropdown integration
* - Language selector integration
* - Notification dropdown integration
* - Sandbox toggle integration
* - Search input
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import TopBar from '../TopBar';
import { User } from '../../types';
// Mock child components
vi.mock('../UserProfileDropdown', () => ({
default: ({ user }: { user: User }) => (
<div data-testid="user-profile-dropdown">User: {user.email}</div>
),
}));
vi.mock('../LanguageSelector', () => ({
default: () => <div data-testid="language-selector">Language Selector</div>,
}));
vi.mock('../NotificationDropdown', () => ({
default: ({ onTicketClick }: { onTicketClick?: (id: string) => void }) => (
<div data-testid="notification-dropdown">Notifications</div>
),
}));
vi.mock('../SandboxToggle', () => ({
default: ({ isSandbox, sandboxEnabled, onToggle, isToggling }: any) => (
<div data-testid="sandbox-toggle">
Sandbox: {isSandbox ? 'On' : 'Off'}
<button onClick={onToggle} disabled={isToggling}>
Toggle Sandbox
</button>
</div>
),
}));
// Mock SandboxContext
const mockUseSandbox = vi.fn();
vi.mock('../../contexts/SandboxContext', () => ({
useSandbox: () => mockUseSandbox(),
}));
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.search': 'Search...',
};
return translations[key] || key;
},
}),
}));
// Test data factory for User objects
const createMockUser = (overrides?: Partial<User>): User => ({
id: '1',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
role: 'owner',
phone: '+1234567890',
preferences: {
email: true,
sms: false,
in_app: true,
},
twoFactorEnabled: false,
profilePictureUrl: undefined,
...overrides,
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('TopBar', () => {
const mockToggleTheme = vi.fn();
const mockOnMenuClick = vi.fn();
const mockOnTicketClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseSandbox.mockReturnValue({
isSandbox: false,
sandboxEnabled: true,
toggleSandbox: vi.fn(),
isToggling: false,
});
});
describe('Rendering', () => {
it('should render the top bar with all main elements', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
it('should render search input on desktop', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveClass('w-full');
});
it('should render mobile menu button', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toBeInTheDocument();
});
it('should pass user to UserProfileDropdown', () => {
const user = createMockUser({ email: 'john@example.com' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByText('User: john@example.com')).toBeInTheDocument();
});
it('should render with dark mode styles when isDarkMode is true', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={true}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('dark:bg-gray-800', 'dark:border-gray-700');
});
});
describe('Theme Toggle', () => {
it('should render moon icon when in light mode', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// The button should exist
const buttons = screen.getAllByRole('button');
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400')
);
expect(themeButton).toBeInTheDocument();
});
it('should render sun icon when in dark mode', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={true}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// The button should exist
const buttons = screen.getAllByRole('button');
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400')
);
expect(themeButton).toBeInTheDocument();
});
it('should call toggleTheme when theme button is clicked', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Find the theme toggle button by finding buttons, then clicking the one with the theme classes
const buttons = screen.getAllByRole('button');
// The theme button is the one with the hover styles and not the menu button
const themeButton = buttons.find(btn =>
btn.className.includes('text-gray-400') &&
btn.className.includes('hover:text-gray-600') &&
!btn.getAttribute('aria-label')
);
expect(themeButton).toBeTruthy();
if (themeButton) {
fireEvent.click(themeButton);
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
}
});
});
describe('Mobile Menu Button', () => {
it('should render menu button with correct aria-label', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveAttribute('aria-label', 'Open sidebar');
});
it('should call onMenuClick when menu button is clicked', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
fireEvent.click(menuButton);
expect(mockOnMenuClick).toHaveBeenCalledTimes(1);
});
it('should have mobile-only classes on menu button', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('md:hidden');
});
});
describe('Search Input', () => {
it('should render search input with correct placeholder', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toHaveAttribute('type', 'text');
});
it('should have search icon', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Search icon should be present
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput.parentElement?.querySelector('span')).toBeInTheDocument();
});
it('should allow typing in search input', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: 'test query' } });
expect(searchInput.value).toBe('test query');
});
it('should have focus styles on search input', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const searchInput = screen.getByPlaceholderText('Search...');
expect(searchInput).toHaveClass('focus:outline-none', 'focus:border-brand-500');
});
});
describe('Sandbox Integration', () => {
it('should render SandboxToggle component', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
it('should pass sandbox state to SandboxToggle', () => {
const user = createMockUser();
mockUseSandbox.mockReturnValue({
isSandbox: true,
sandboxEnabled: true,
toggleSandbox: vi.fn(),
isToggling: false,
});
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByText(/Sandbox: On/i)).toBeInTheDocument();
});
it('should handle sandbox toggle being disabled', () => {
const user = createMockUser();
mockUseSandbox.mockReturnValue({
isSandbox: false,
sandboxEnabled: false,
toggleSandbox: vi.fn(),
isToggling: false,
});
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('sandbox-toggle')).toBeInTheDocument();
});
});
describe('Notification Integration', () => {
it('should render NotificationDropdown', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
it('should pass onTicketClick to NotificationDropdown when provided', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
onTicketClick={mockOnTicketClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
it('should work without onTicketClick prop', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('notification-dropdown')).toBeInTheDocument();
});
});
describe('Language Selector Integration', () => {
it('should render LanguageSelector', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('language-selector')).toBeInTheDocument();
});
});
describe('Different User Roles', () => {
it('should render for owner role', () => {
const user = createMockUser({ role: 'owner' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for manager role', () => {
const user = createMockUser({ role: 'manager' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for staff role', () => {
const user = createMockUser({ role: 'staff' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
it('should render for platform roles', () => {
const user = createMockUser({ role: 'platform_manager' });
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(screen.getByTestId('user-profile-dropdown')).toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should have fixed height', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('h-16');
});
it('should have border at bottom', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('border-b');
});
it('should use flexbox layout', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
});
it('should have responsive padding', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const header = container.querySelector('header');
expect(header).toHaveClass('px-4', 'sm:px-8');
});
});
describe('Accessibility', () => {
it('should have semantic header element', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
expect(container.querySelector('header')).toBeInTheDocument();
});
it('should have proper button roles', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('should have focus styles on interactive elements', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('focus:outline-none', 'focus:ring-2');
});
});
describe('Responsive Behavior', () => {
it('should hide search on mobile', () => {
const user = createMockUser();
const { container } = renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
// Search container is a relative div with hidden md:block classes
const searchContainer = container.querySelector('.hidden.md\\:block');
expect(searchContainer).toBeInTheDocument();
});
it('should show menu button only on mobile', () => {
const user = createMockUser();
renderWithRouter(
<TopBar
user={user}
isDarkMode={false}
toggleTheme={mockToggleTheme}
onMenuClick={mockOnMenuClick}
/>
);
const menuButton = screen.getByLabelText('Open sidebar');
expect(menuButton).toHaveClass('md:hidden');
});
});
});

View File

@@ -508,4 +508,230 @@ describe('TrialBanner', () => {
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
});
describe('Additional Edge Cases', () => {
it('should handle negative days left gracefully', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: -5,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render (backend shouldn't send this, but defensive coding)
expect(screen.getByText(/-5 days left in trial/i)).toBeInTheDocument();
});
it('should handle fractional days by rounding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5.7 as number,
});
renderWithRouter(<TrialBanner business={business} />);
// Should display with the value received
expect(screen.getByText(/5.7 days left in trial/i)).toBeInTheDocument();
});
it('should transition from urgent to non-urgent styling on update', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 3,
});
const { container, rerender } = renderWithRouter(<TrialBanner business={business} />);
// Initially urgent
expect(container.querySelector('.from-red-500')).toBeInTheDocument();
// Update to non-urgent
const updatedBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should now be non-urgent
expect(container.querySelector('.from-blue-600')).toBeInTheDocument();
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
});
it('should handle business without name gracefully', () => {
const business = createMockBusiness({
name: '',
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
// Should still render the banner
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
});
it('should handle switching from active to inactive trial', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 5,
});
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
// Update to inactive
const updatedBusiness = createMockBusiness({
isTrialActive: false,
daysLeftInTrial: 5,
});
rerender(
<BrowserRouter>
<TrialBanner business={updatedBusiness} />
</BrowserRouter>
);
// Should no longer render
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
});
});
describe('Button Interactions', () => {
it('should prevent multiple rapid clicks on upgrade button', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
// Rapid clicks
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
fireEvent.click(upgradeButton);
// Navigate should still only be called once per click (no debouncing in component)
expect(mockNavigate).toHaveBeenCalledTimes(3);
});
it('should not interfere with other buttons after dismiss', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissButton);
// Banner is gone
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
// Upgrade button should also be gone
expect(screen.queryByRole('button', { name: /upgrade now/i })).not.toBeInTheDocument();
});
});
describe('Visual States', () => {
it('should have shadow and proper background for visibility', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const banner = container.querySelector('.shadow-md');
expect(banner).toBeInTheDocument();
});
it('should have gradient background for visual appeal', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
const gradient = container.querySelector('.bg-gradient-to-r');
expect(gradient).toBeInTheDocument();
});
it('should show hover states on interactive elements', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
renderWithRouter(<TrialBanner business={business} />);
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
expect(upgradeButton).toHaveClass('hover:bg-blue-50');
});
it('should have appropriate spacing and padding', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Check for padding classes
const contentContainer = container.querySelector('.py-3');
expect(contentContainer).toBeInTheDocument();
});
});
describe('Icon Rendering', () => {
it('should render icons with proper size', () => {
const business = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container } = renderWithRouter(<TrialBanner business={business} />);
// Icons should have consistent size classes
const iconContainer = container.querySelector('.rounded-full');
expect(iconContainer).toBeInTheDocument();
});
it('should show different icons for urgent vs non-urgent states', () => {
const nonUrgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 10,
});
const { container: container1, unmount } = renderWithRouter(
<TrialBanner business={nonUrgentBusiness} />
);
// Non-urgent should not have pulse animation
expect(container1.querySelector('.animate-pulse')).not.toBeInTheDocument();
unmount();
const urgentBusiness = createMockBusiness({
isTrialActive: true,
daysLeftInTrial: 2,
});
const { container: container2 } = renderWithRouter(
<TrialBanner business={urgentBusiness} />
);
// Urgent should have pulse animation
expect(container2.querySelector('.animate-pulse')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,567 @@
/**
* Unit tests for UpgradePrompt, LockedSection, and LockedButton components
*
* Tests upgrade prompts that appear when features are not available in the current plan.
* Covers:
* - Different variants (inline, banner, overlay)
* - Different sizes (sm, md, lg)
* - Feature names and descriptions
* - Navigation to billing page
* - LockedSection wrapper behavior
* - LockedButton disabled state and tooltip
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import {
UpgradePrompt,
LockedSection,
LockedButton,
} from '../UpgradePrompt';
import { FeatureKey } from '../../hooks/usePlanFeatures';
// Mock react-router-dom's Link component
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
Link: ({ to, children, className, ...props }: any) => (
<a href={to} className={className} {...props}>
{children}
</a>
),
};
});
// Wrapper component that provides router context
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('UpgradePrompt', () => {
describe('Inline Variant', () => {
it('should render inline upgrade prompt with lock icon', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="inline" />);
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
// Check for styling classes
const container = screen.getByText('Upgrade Required').parentElement;
expect(container).toHaveClass('bg-amber-50', 'text-amber-700');
});
it('should render small badge style for inline variant', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="webhooks" variant="inline" />
);
const badge = container.querySelector('.bg-amber-50');
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass('text-xs', 'rounded-md');
});
it('should not show description or upgrade button in inline variant', () => {
renderWithRouter(<UpgradePrompt feature="api_access" variant="inline" />);
expect(screen.queryByText(/integrate with external/i)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
});
it('should render for any feature in inline mode', () => {
const features: FeatureKey[] = ['plugins', 'custom_domain', 'white_label'];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<UpgradePrompt feature={feature} variant="inline" />
);
expect(screen.getByText('Upgrade Required')).toBeInTheDocument();
unmount();
});
});
});
describe('Banner Variant', () => {
it('should render banner with feature name and crown icon', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should render feature description by default', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" variant="banner" />);
expect(
screen.getByText(/send automated sms reminders to customers and staff/i)
).toBeInTheDocument();
});
it('should hide description when showDescription is false', () => {
renderWithRouter(
<UpgradePrompt
feature="sms_reminders"
variant="banner"
showDescription={false}
/>
);
expect(
screen.queryByText(/send automated sms reminders/i)
).not.toBeInTheDocument();
});
it('should render upgrade button linking to billing settings', () => {
renderWithRouter(<UpgradePrompt feature="webhooks" variant="banner" />);
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeLink).toBeInTheDocument();
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
});
it('should have gradient styling for banner variant', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="api_access" variant="banner" />
);
const banner = container.querySelector('.bg-gradient-to-br.from-amber-50');
expect(banner).toBeInTheDocument();
expect(banner).toHaveClass('border-2', 'border-amber-300');
});
it('should render crown icon in banner', () => {
renderWithRouter(<UpgradePrompt feature="custom_domain" variant="banner" />);
// Crown icon should be in the button text
const upgradeButton = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeButton).toBeInTheDocument();
});
it('should render all feature names correctly', () => {
const features: FeatureKey[] = [
'webhooks',
'api_access',
'custom_domain',
'white_label',
'plugins',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<UpgradePrompt feature={feature} variant="banner" />
);
// Feature name should be in the heading
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
unmount();
});
});
});
describe('Overlay Variant', () => {
it('should render overlay with blurred children', () => {
renderWithRouter(
<UpgradePrompt feature="sms_reminders" variant="overlay">
<div data-testid="locked-content">Locked Content</div>
</UpgradePrompt>
);
const lockedContent = screen.getByTestId('locked-content');
expect(lockedContent).toBeInTheDocument();
// Check that parent has blur styling
const parent = lockedContent.parentElement;
expect(parent).toHaveClass('blur-sm', 'opacity-50');
});
it('should render feature name and description in overlay', () => {
renderWithRouter(
<UpgradePrompt feature="webhooks" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
expect(screen.getByText('Webhooks')).toBeInTheDocument();
expect(
screen.getByText(/integrate with external services using webhooks/i)
).toBeInTheDocument();
});
it('should render lock icon in overlay', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="api_access" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
// Lock icon should be in a rounded circle
const iconCircle = container.querySelector('.rounded-full.bg-gradient-to-br');
expect(iconCircle).toBeInTheDocument();
});
it('should render upgrade button in overlay', () => {
renderWithRouter(
<UpgradePrompt feature="custom_domain" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const upgradeLink = screen.getByRole('link', { name: /upgrade your plan/i });
expect(upgradeLink).toBeInTheDocument();
expect(upgradeLink).toHaveAttribute('href', '/settings/billing');
});
it('should apply small size styling', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay" size="sm">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-4');
expect(overlayContent).toBeInTheDocument();
});
it('should apply medium size styling by default', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-6');
expect(overlayContent).toBeInTheDocument();
});
it('should apply large size styling', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay" size="lg">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-8');
expect(overlayContent).toBeInTheDocument();
});
it('should make children non-interactive', () => {
renderWithRouter(
<UpgradePrompt feature="white_label" variant="overlay">
<button data-testid="locked-button">Click Me</button>
</UpgradePrompt>
);
const button = screen.getByTestId('locked-button');
const parent = button.parentElement;
expect(parent).toHaveClass('pointer-events-none');
});
});
describe('Default Behavior', () => {
it('should default to banner variant when no variant specified', () => {
renderWithRouter(<UpgradePrompt feature="sms_reminders" />);
// Banner should show feature name in heading
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should show description by default', () => {
renderWithRouter(<UpgradePrompt feature="webhooks" />);
expect(
screen.getByText(/integrate with external services/i)
).toBeInTheDocument();
});
it('should use medium size by default', () => {
const { container } = renderWithRouter(
<UpgradePrompt feature="plugins" variant="overlay">
<div>Content</div>
</UpgradePrompt>
);
const overlayContent = container.querySelector('.p-6');
expect(overlayContent).toBeInTheDocument();
});
});
});
describe('LockedSection', () => {
describe('Unlocked State', () => {
it('should render children when not locked', () => {
renderWithRouter(
<LockedSection feature="sms_reminders" isLocked={false}>
<div data-testid="content">Available Content</div>
</LockedSection>
);
expect(screen.getByTestId('content')).toBeInTheDocument();
expect(screen.getByText('Available Content')).toBeInTheDocument();
});
it('should not show upgrade prompt when unlocked', () => {
renderWithRouter(
<LockedSection feature="webhooks" isLocked={false}>
<div>Content</div>
</LockedSection>
);
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /upgrade your plan/i })).not.toBeInTheDocument();
});
});
describe('Locked State', () => {
it('should show banner prompt by default when locked', () => {
renderWithRouter(
<LockedSection feature="sms_reminders" isLocked={true}>
<div>Content</div>
</LockedSection>
);
expect(screen.getByText(/sms reminders.*upgrade required/i)).toBeInTheDocument();
});
it('should show overlay prompt when variant is overlay', () => {
renderWithRouter(
<LockedSection feature="api_access" isLocked={true} variant="overlay">
<div data-testid="locked-content">Locked Content</div>
</LockedSection>
);
expect(screen.getByTestId('locked-content')).toBeInTheDocument();
expect(screen.getByText('API Access')).toBeInTheDocument();
});
it('should show fallback content instead of upgrade prompt when provided', () => {
renderWithRouter(
<LockedSection
feature="custom_domain"
isLocked={true}
fallback={<div data-testid="fallback">Custom Fallback</div>}
>
<div>Original Content</div>
</LockedSection>
);
expect(screen.getByTestId('fallback')).toBeInTheDocument();
expect(screen.getByText('Custom Fallback')).toBeInTheDocument();
expect(screen.queryByText(/upgrade required/i)).not.toBeInTheDocument();
});
it('should not render original children when locked without overlay', () => {
renderWithRouter(
<LockedSection feature="webhooks" isLocked={true} variant="banner">
<div data-testid="original">Original Content</div>
</LockedSection>
);
expect(screen.queryByTestId('original')).not.toBeInTheDocument();
expect(screen.getByText(/webhooks.*upgrade required/i)).toBeInTheDocument();
});
it('should render blurred children with overlay variant', () => {
renderWithRouter(
<LockedSection feature="plugins" isLocked={true} variant="overlay">
<div data-testid="blurred-content">Blurred Content</div>
</LockedSection>
);
const content = screen.getByTestId('blurred-content');
expect(content).toBeInTheDocument();
expect(content.parentElement).toHaveClass('blur-sm');
});
});
describe('Different Features', () => {
it('should work with different feature keys', () => {
const features: FeatureKey[] = [
'white_label',
'custom_oauth',
'can_create_plugins',
'tasks',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<LockedSection feature={feature} isLocked={true}>
<div>Content</div>
</LockedSection>
);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
unmount();
});
});
});
});
describe('LockedButton', () => {
describe('Unlocked State', () => {
it('should render normal clickable button when not locked', () => {
const handleClick = vi.fn();
renderWithRouter(
<LockedButton
feature="sms_reminders"
isLocked={false}
onClick={handleClick}
className="custom-class"
>
Click Me
</LockedButton>
);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
expect(button).toHaveClass('custom-class');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should not show lock icon when unlocked', () => {
renderWithRouter(
<LockedButton feature="webhooks" isLocked={false}>
Submit
</LockedButton>
);
const button = screen.getByRole('button', { name: /submit/i });
expect(button.querySelector('svg')).not.toBeInTheDocument();
});
});
describe('Locked State', () => {
it('should render disabled button with lock icon when locked', () => {
renderWithRouter(
<LockedButton feature="api_access" isLocked={true}>
Submit
</LockedButton>
);
const button = screen.getByRole('button', { name: /submit/i });
expect(button).toBeDisabled();
expect(button).toHaveClass('opacity-50', 'cursor-not-allowed');
});
it('should display lock icon when locked', () => {
renderWithRouter(
<LockedButton feature="custom_domain" isLocked={true}>
Save
</LockedButton>
);
const button = screen.getByRole('button');
expect(button.textContent).toContain('Save');
});
it('should show tooltip on hover when locked', () => {
const { container } = renderWithRouter(
<LockedButton feature="plugins" isLocked={true}>
Create Plugin
</LockedButton>
);
// Tooltip should exist in DOM
const tooltip = container.querySelector('.opacity-0');
expect(tooltip).toBeInTheDocument();
expect(tooltip?.textContent).toContain('Upgrade Required');
});
it('should not trigger onClick when locked', () => {
const handleClick = vi.fn();
renderWithRouter(
<LockedButton
feature="white_label"
isLocked={true}
onClick={handleClick}
>
Click Me
</LockedButton>
);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
it('should apply custom className even when locked', () => {
renderWithRouter(
<LockedButton
feature="webhooks"
isLocked={true}
className="custom-btn"
>
Submit
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-btn');
});
it('should display feature name in tooltip', () => {
const { container } = renderWithRouter(
<LockedButton feature="sms_reminders" isLocked={true}>
Send SMS
</LockedButton>
);
const tooltip = container.querySelector('.whitespace-nowrap');
expect(tooltip?.textContent).toContain('SMS Reminders');
});
});
describe('Different Features', () => {
it('should work with various feature keys', () => {
const features: FeatureKey[] = [
'export_data',
'video_conferencing',
'two_factor_auth',
'masked_calling',
];
features.forEach((feature) => {
const { unmount } = renderWithRouter(
<LockedButton feature={feature} isLocked={true}>
Action
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
unmount();
});
});
});
describe('Accessibility', () => {
it('should have proper button role when unlocked', () => {
renderWithRouter(
<LockedButton feature="plugins" isLocked={false}>
Save
</LockedButton>
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should have proper button role when locked', () => {
renderWithRouter(
<LockedButton feature="webhooks" isLocked={true}>
Submit
</LockedButton>
);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should indicate disabled state for screen readers', () => {
renderWithRouter(
<LockedButton feature="api_access" isLocked={true}>
Create
</LockedButton>
);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('disabled');
});
});
});

View File

@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import { Mail, Lock, User as UserIcon, ArrowRight, Shield } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../../api/client';
export interface User {
id: string | number;
name: string;
email: string;
}
interface AuthSectionProps {
onLogin: (user: User) => void;
}
export const AuthSection: React.FC<AuthSectionProps> = ({ onLogin }) => {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [loading, setLoading] = useState(false);
// Email verification states
const [needsVerification, setNeedsVerification] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const [verifyingCode, setVerifyingCode] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await api.post('/auth/login/', {
username: email,
password: password
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.email,
};
toast.success('Welcome back!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Login failed');
} finally {
setLoading(false);
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
// Validate passwords match
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
// Validate password length
if (password.length < 8) {
toast.error('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
// Send verification email
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('Verification code sent to your email!');
setNeedsVerification(true);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Failed to send verification code');
} finally {
setLoading(false);
}
};
const handleVerifyCode = async (e: React.FormEvent) => {
e.preventDefault();
setVerifyingCode(true);
try {
// Verify code and create account
const response = await api.post('/auth/verify-and-register/', {
email: email,
first_name: firstName,
last_name: lastName,
password: password,
verification_code: verificationCode
});
const user: User = {
id: response.data.user.id,
email: response.data.user.email,
name: response.data.user.full_name || response.data.user.name,
};
toast.success('Account created successfully!');
onLogin(user);
} catch (error: any) {
toast.error(error?.response?.data?.detail || 'Verification failed');
} finally {
setVerifyingCode(false);
}
};
const handleResendCode = async () => {
setLoading(true);
try {
await api.post('/auth/send-verification/', {
email: email,
first_name: firstName,
last_name: lastName
});
toast.success('New code sent!');
} catch (error: any) {
toast.error('Failed to resend code');
} finally {
setLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
if (isLogin) {
handleLogin(e);
} else {
handleSignup(e);
}
};
// Show verification step for new customers
if (needsVerification && !isLogin) {
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-indigo-600 dark:text-indigo-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Verify Your Email</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
We've sent a 6-digit code to <span className="font-medium text-gray-900 dark:text-white">{email}</span>
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleVerifyCode} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Verification Code
</label>
<input
type="text"
required
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="block w-full px-4 py-3 text-center text-2xl font-mono tracking-widest border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="000000"
maxLength={6}
autoFocus
/>
</div>
<button
type="submit"
disabled={verifyingCode || verificationCode.length !== 6}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{verifyingCode ? (
<span className="animate-pulse">Verifying...</span>
) : (
<>
Verify & Continue
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center space-y-2">
<button
type="button"
onClick={handleResendCode}
disabled={loading}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 disabled:opacity-50"
>
Resend Code
</button>
<div>
<button
type="button"
onClick={() => {
setNeedsVerification(false);
setVerificationCode('');
}}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Change email address
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
{isLogin
? 'Sign in to access your bookings and history.'
: 'Join us to book your first premium service.'}
</p>
</div>
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700">
<form onSubmit={handleSubmit} className="space-y-5">
{!isLogin && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserIcon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
required={!isLogin}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
<input
type="text"
required={!isLogin}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="block w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="Doe"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
minLength={isLogin ? undefined : 8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="••••••••"
/>
</div>
{!isLogin && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Must be at least 8 characters</p>
)}
</div>
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<input
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`block w-full pl-10 pr-3 py-2.5 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-indigo-500 focus:border-indigo-500 transition-colors ${
confirmPassword && password !== confirmPassword
? 'border-red-300 dark:border-red-500'
: 'border-gray-300 dark:border-gray-600'
}`}
placeholder="••••••••"
/>
</div>
{confirmPassword && password !== confirmPassword && (
<p className="mt-1 text-xs text-red-500">Passwords do not match</p>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{loading ? (
<span className="animate-pulse">Processing...</span>
) : (
<>
{isLogin ? 'Sign In' : 'Create Account'}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setConfirmPassword('');
setFirstName('');
setLastName('');
}}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
>
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { usePublicServices, useCreateBooking } from '../../hooks/useBooking';
import { Loader2 } from 'lucide-react';
interface BookingWidgetProps {
headline?: string;
subheading?: string;
accentColor?: string;
buttonLabel?: string;
}
export const BookingWidget: React.FC<BookingWidgetProps> = ({
headline = "Book Appointment",
subheading = "Select a service",
accentColor = "#2563eb",
buttonLabel = "Book Now"
}) => {
const { data: services, isLoading } = usePublicServices();
const createBooking = useCreateBooking();
const [selectedService, setSelectedService] = useState<any>(null);
if (isLoading) return <div className="flex justify-center"><Loader2 className="animate-spin" /></div>;
const handleBook = async () => {
if (!selectedService) return;
try {
await createBooking.mutateAsync({ service_id: selectedService.id });
alert("Booking created (stub)!");
} catch (e) {
console.error(e);
alert("Error creating booking");
}
};
return (
<div className="booking-widget p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-900/30 max-w-md mx-auto text-left border border-gray-100 dark:border-gray-700">
<h2 className="text-2xl font-bold mb-2 text-indigo-600 dark:text-indigo-400">{headline}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">{subheading}</p>
<div className="space-y-4 mb-6">
{services?.length === 0 && <p className="text-gray-600 dark:text-gray-400">No services available.</p>}
{services?.map((service: any) => (
<div
key={service.id}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedService?.id === service.id
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 dark:border-indigo-400'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 bg-white dark:bg-gray-700/50'
}`}
onClick={() => setSelectedService(service)}
>
<h3 className="font-semibold text-gray-900 dark:text-white">{service.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{service.duration} min - ${(service.price_cents / 100).toFixed(2)}</p>
</div>
))}
</div>
<button
onClick={handleBook}
disabled={!selectedService}
className="w-full py-3 px-4 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white font-semibold disabled:opacity-50 hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md"
>
{buttonLabel}
</button>
</div>
);
};
export default BookingWidget;

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CheckCircle, Calendar, MapPin, ArrowRight } from 'lucide-react';
import { PublicService } from '../../hooks/useBooking';
import { User } from './AuthSection';
interface BookingState {
step: number;
service: PublicService | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
interface ConfirmationProps {
booking: BookingState;
}
export const Confirmation: React.FC<ConfirmationProps> = ({ booking }) => {
const navigate = useNavigate();
if (!booking.service || !booking.date || !booking.timeSlot) return null;
// Generate a pseudo-random booking reference based on timestamp
const bookingRef = `BK-${Date.now().toString().slice(-6)}`;
return (
<div className="text-center max-w-2xl mx-auto py-10">
<div className="mb-6 flex justify-center">
<div className="h-24 w-24 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Booking Confirmed!</h2>
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
Thank you, {booking.user?.name}. Your appointment has been successfully scheduled.
</p>
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden text-left">
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
<h3 className="font-semibold text-gray-900 dark:text-white">Booking Details</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Ref: #{bookingRef}</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start">
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center flex-shrink-0 mr-4">
{booking.service.photos && booking.service.photos.length > 0 ? (
<img src={booking.service.photos[0]} className="h-12 w-12 rounded-lg object-cover" alt="" />
) : (
<div className="h-12 w-12 rounded-lg bg-indigo-200 dark:bg-indigo-800" />
)}
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{booking.service.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">{booking.service.duration} minutes</p>
</div>
<div className="ml-auto text-right">
<p className="font-medium text-gray-900 dark:text-white">${(booking.service.price_cents / 100).toFixed(2)}</p>
{booking.service.deposit_amount_cents && booking.service.deposit_amount_cents > 0 && (
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Deposit Paid</p>
)}
</div>
</div>
<div className="border-t border-gray-100 dark:border-gray-700 pt-4 flex flex-col sm:flex-row sm:justify-between gap-4">
<div className="flex items-center text-gray-700 dark:text-gray-300">
<Calendar className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Date & Time</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{booking.date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} at {booking.timeSlot}
</p>
</div>
</div>
<div className="flex items-center text-gray-700 dark:text-gray-300">
<MapPin className="w-5 h-5 mr-3 text-indigo-500 dark:text-indigo-400" />
<div>
<p className="text-sm font-medium">Location</p>
<p className="text-sm text-gray-500 dark:text-gray-400">See confirmation email</p>
</div>
</div>
</div>
</div>
</div>
<p className="mt-6 text-sm text-gray-500 dark:text-gray-400">
A confirmation email has been sent to {booking.user?.email}.
</p>
<div className="mt-8 flex justify-center space-x-4">
<button
onClick={() => navigate('/')}
className="flex items-center px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors shadow-lg"
>
Done
<ArrowRight className="w-4 h-4 ml-2" />
</button>
<button
onClick={() => {
// Clear booking state and start fresh
sessionStorage.removeItem('booking_state');
navigate('/book');
}}
className="px-6 py-3 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Book Another
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,276 @@
import React, { useMemo } from 'react';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2, XCircle } from 'lucide-react';
import { usePublicAvailability, usePublicBusinessHours } from '../../hooks/useBooking';
import { formatTimeForDisplay, getTimezoneAbbreviation, getUserTimezone } from '../../utils/dateUtils';
interface DateTimeSelectionProps {
serviceId?: number;
selectedDate: Date | null;
selectedTimeSlot: string | null;
onDateChange: (date: Date) => void;
onTimeChange: (time: string) => void;
}
export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
serviceId,
selectedDate,
selectedTimeSlot,
onDateChange,
onTimeChange
}) => {
const today = new Date();
const [currentMonth, setCurrentMonth] = React.useState(today.getMonth());
const [currentYear, setCurrentYear] = React.useState(today.getFullYear());
// Calculate date range for business hours query (current month view)
const { startDate, endDate } = useMemo(() => {
const start = new Date(currentYear, currentMonth, 1);
const end = new Date(currentYear, currentMonth + 1, 0);
return {
startDate: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-01`,
endDate: `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
};
}, [currentMonth, currentYear]);
// Fetch business hours for the month
const { data: businessHours, isLoading: businessHoursLoading } = usePublicBusinessHours(startDate, endDate);
// Create a map of dates to their open status
const openDaysMap = useMemo(() => {
const map = new Map<string, boolean>();
if (businessHours?.dates) {
businessHours.dates.forEach(day => {
map.set(day.date, day.is_open);
});
}
return map;
}, [businessHours]);
// Format selected date for API query (YYYY-MM-DD)
const dateString = selectedDate
? `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, '0')}-${String(selectedDate.getDate()).padStart(2, '0')}`
: undefined;
// Fetch availability when both serviceId and date are set
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();
const handlePrevMonth = () => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(currentYear - 1);
} else {
setCurrentMonth(currentMonth - 1);
}
};
const handleNextMonth = () => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(currentYear + 1);
} else {
setCurrentMonth(currentMonth + 1);
}
};
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const monthName = new Date(currentYear, currentMonth).toLocaleString('default', { month: 'long' });
const isSelected = (day: number) => {
return selectedDate?.getDate() === day &&
selectedDate?.getMonth() === currentMonth &&
selectedDate?.getFullYear() === currentYear;
};
const isPast = (day: number) => {
const d = new Date(currentYear, currentMonth, day);
const now = new Date();
now.setHours(0, 0, 0, 0);
return d < now;
};
const isClosed = (day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// If we have business hours data, use it. Otherwise default to open (except past dates)
if (openDaysMap.size > 0) {
return openDaysMap.get(dateStr) === false;
}
return false;
};
const isDisabled = (day: number) => {
return isPast(day) || isClosed(day);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Calendar Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CalendarIcon className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Select Date
</h3>
<div className="flex space-x-2">
<button onClick={handlePrevMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronLeft className="w-5 h-5" />
</button>
<span className="font-medium text-gray-900 dark:text-white w-32 text-center">
{monthName} {currentYear}
</span>
<button onClick={handleNextMonth} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-gray-600 dark:text-gray-400">
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2 mb-2 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
</div>
{businessHoursLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : (
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: firstDayOfMonth }).map((_, i) => (
<div key={`empty-${i}`} />
))}
{days.map((day) => {
const past = isPast(day);
const closed = isClosed(day);
const disabled = isDisabled(day);
const selected = isSelected(day);
return (
<button
key={day}
disabled={disabled}
onClick={() => {
const newDate = new Date(currentYear, currentMonth, day);
onDateChange(newDate);
}}
className={`
h-10 w-10 rounded-full flex items-center justify-center text-sm font-medium transition-all relative
${selected
? 'bg-indigo-600 dark:bg-indigo-500 text-white shadow-md'
: closed
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: past
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-200 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
title={closed ? 'Business closed' : past ? 'Past date' : undefined}
>
{day}
</button>
);
})}
</div>
)}
{/* Legend */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-gray-100 dark:bg-gray-700"></div>
<span>Closed</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-indigo-600 dark:bg-indigo-500"></div>
<span>Selected</span>
</div>
</div>
</div>
{/* Time Slots Section */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm flex flex-col">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Available Time Slots</h3>
{!selectedDate ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a date first
</div>
) : availabilityLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
) : isError ? (
<div className="flex-1 flex flex-col items-center justify-center text-red-500 dark:text-red-400">
<XCircle className="w-12 h-12 mb-3" />
<p className="font-medium">Failed to load availability</p>
<p className="text-sm mt-1 text-gray-500 dark:text-gray-400">
{error instanceof Error ? error.message : 'Please try again'}
</p>
</div>
) : availability?.is_open === false ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<XCircle className="w-12 h-12 mb-3 text-gray-300 dark:text-gray-600" />
<p className="font-medium">Business Closed</p>
<p className="text-sm mt-1">Please select another date</p>
</div>
) : availability?.slots && availability.slots.length > 0 ? (
<>
{(() => {
// Determine which timezone to display based on business settings
const displayTimezone = availability.timezone_display_mode === 'viewer'
? getUserTimezone()
: availability.business_timezone || getUserTimezone();
const tzAbbrev = getTimezoneAbbreviation(displayTimezone);
return (
<>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
{availability.business_hours && (
<>Business hours: {availability.business_hours.start} - {availability.business_hours.end} </>
)}
Times shown in {tzAbbrev}
</p>
<div className="grid grid-cols-2 gap-3">
{availability.slots.map((slot) => {
// Format time in the appropriate timezone
const displayTime = formatTimeForDisplay(
slot.time,
availability.timezone_display_mode === 'viewer' ? null : availability.business_timezone
);
return (
<button
key={slot.time}
disabled={!slot.available}
onClick={() => onTimeChange(displayTime)}
className={`
py-3 px-4 rounded-lg text-sm font-medium border transition-all duration-200
${!slot.available
? 'bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-600 cursor-not-allowed'
: selectedTimeSlot === displayTime
? 'bg-indigo-600 dark:bg-indigo-500 text-white border-indigo-600 dark:border-indigo-500 shadow-sm'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-indigo-500 dark:hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-400'
}
`}
>
{displayTime}
{!slot.available && <span className="block text-[10px] font-normal">Booked</span>}
</button>
);
})}
</div>
</>
);
})()}
</>
) : !serviceId ? (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
Please select a service first
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 italic">
No available time slots for this date
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,134 @@
import React, { useState, useRef, useEffect } from 'react';
import { MessageCircle, X, Send, Sparkles } from 'lucide-react';
import { BookingState, ChatMessage } from './types';
// TODO: Implement Gemini service
const sendMessageToGemini = async (message: string, bookingState: BookingState): Promise<string> => {
// Mock implementation - replace with actual Gemini API call
return "I'm here to help you book your appointment. Please use the booking form above.";
};
interface GeminiChatProps {
currentBookingState: BookingState;
}
export const GeminiChat: React.FC<GeminiChatProps> = ({ currentBookingState }) => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: 'Hi! I can help you choose a service or answer questions about booking.' }
]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
const handleSend = async () => {
if (!inputText.trim() || isLoading) return;
const userMsg: ChatMessage = { role: 'user', text: inputText };
setMessages(prev => [...prev, userMsg]);
setInputText('');
setIsLoading(true);
try {
const responseText = await sendMessageToGemini(inputText, messages, currentBookingState);
setMessages(prev => [...prev, { role: 'model', text: responseText }]);
} catch (error) {
setMessages(prev => [...prev, { role: 'model', text: "Sorry, I'm having trouble connecting." }]);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
{/* Chat Window */}
{isOpen && (
<div className="bg-white w-80 sm:w-96 h-[500px] rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden mb-4 animate-in slide-in-from-bottom-10 fade-in duration-200">
<div className="bg-indigo-600 p-4 flex justify-between items-center text-white">
<div className="flex items-center space-x-2">
<Sparkles className="w-4 h-4" />
<span className="font-semibold">Lumina Assistant</span>
</div>
<button onClick={() => setIsOpen(false)} className="hover:bg-indigo-500 rounded-full p-1 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 scrollbar-hide">
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`
max-w-[80%] px-4 py-2 rounded-2xl text-sm
${msg.role === 'user'
? 'bg-indigo-600 text-white rounded-br-none'
: 'bg-white text-gray-800 border border-gray-200 shadow-sm rounded-bl-none'
}
`}
>
{msg.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white px-4 py-2 rounded-2xl rounded-bl-none border border-gray-200 shadow-sm">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-3 bg-white border-t border-gray-100">
<form
onSubmit={(e) => { e.preventDefault(); handleSend(); }}
className="flex items-center gap-2"
>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Ask about services..."
className="flex-1 px-4 py-2 rounded-full border border-gray-300 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm"
/>
<button
type="submit"
disabled={isLoading || !inputText.trim()}
className="p-2 bg-indigo-600 text-white rounded-full hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="w-4 h-4" />
</button>
</form>
</div>
</div>
)}
{/* Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`
p-4 rounded-full shadow-xl transition-all duration-300 flex items-center justify-center
${isOpen ? 'bg-gray-800 rotate-90 scale-0' : 'bg-indigo-600 hover:bg-indigo-700 scale-100'}
`}
style={{display: isOpen ? 'none' : 'flex'}}
>
<MessageCircle className="w-6 h-6 text-white" />
</button>
</div>
);
};

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { PublicService } from '../../hooks/useBooking';
import { CreditCard, ShieldCheck, Lock } from 'lucide-react';
interface PaymentSectionProps {
service: PublicService;
onPaymentComplete: () => void;
}
export const PaymentSection: React.FC<PaymentSectionProps> = ({ service, onPaymentComplete }) => {
const [processing, setProcessing] = useState(false);
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvc, setCvc] = useState('');
// Convert cents to dollars
const price = service.price_cents / 100;
const deposit = (service.deposit_amount_cents || 0) / 100;
// Auto-format card number
const handleCardInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value.replace(/\D/g, '');
val = val.substring(0, 16);
val = val.replace(/(\d{4})/g, '$1 ').trim();
setCardNumber(val);
};
const handlePayment = (e: React.FormEvent) => {
e.preventDefault();
setProcessing(true);
// Simulate Stripe Payment Intent & Processing
setTimeout(() => {
setProcessing(false);
onPaymentComplete();
}, 2000);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Payment Details Column */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<CreditCard className="w-5 h-5 mr-2 text-indigo-600 dark:text-indigo-400" />
Card Details
</h3>
<div className="flex space-x-2">
{/* Mock Card Icons */}
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-6 w-10 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</div>
<form id="payment-form" onSubmit={handlePayment} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Number</label>
<input
type="text"
required
value={cardNumber}
onChange={handleCardInput}
placeholder="0000 0000 0000 0000"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Expiry Date</label>
<input
type="text"
required
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
placeholder="MM / YY"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CVC</label>
<div className="relative">
<input
type="text"
required
value={cvc}
onChange={(e) => setCvc(e.target.value)}
placeholder="123"
className="block w-full px-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 font-mono"
/>
<Lock className="w-4 h-4 text-gray-400 dark:text-gray-500 absolute right-3 top-3.5" />
</div>
</div>
</div>
<div className="mt-4 flex items-start p-4 bg-indigo-50 dark:bg-indigo-900/30 rounded-lg">
<ShieldCheck className="w-5 h-5 text-indigo-600 dark:text-indigo-400 mt-0.5 mr-3 flex-shrink-0" />
<p className="text-sm text-indigo-800 dark:text-indigo-200">
Your payment is secure. We use Stripe to process your payment. {deposit > 0 ? <>A deposit of <strong>${deposit.toFixed(2)}</strong> will be charged now.</> : <>Full payment will be collected at your appointment.</>}
</p>
</div>
</form>
</div>
</div>
{/* Summary Column */}
<div className="lg:col-span-1">
<div className="bg-gray-50 dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 sticky top-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Payment Summary</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Service Total</span>
<span>${price.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Tax (Estimated)</span>
<span>$0.00</span>
</div>
<div className="border-t border-gray-200 dark:border-gray-600 my-2 pt-2"></div>
<div className="flex justify-between items-center text-lg font-bold text-gray-900 dark:text-white">
<span>Total</span>
<span>${price.toFixed(2)}</span>
</div>
</div>
{deposit > 0 ? (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due Now (Deposit)</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${deposit.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center text-sm text-gray-500 dark:text-gray-400">
<span>Due at appointment</span>
<span>${(price - deposit).toFixed(2)}</span>
</div>
</div>
) : (
<div className="mt-6 bg-white dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600 shadow-sm">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-900 dark:text-white">Due at appointment</span>
<span className="text-lg font-bold text-indigo-600 dark:text-indigo-400">${price.toFixed(2)}</span>
</div>
</div>
)}
<button
type="submit"
form="payment-form"
disabled={processing}
className="w-full mt-6 py-3 px-4 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg font-semibold shadow-md hover:bg-indigo-700 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-75 disabled:cursor-not-allowed transition-all"
>
{processing ? 'Processing...' : deposit > 0 ? `Pay $${deposit.toFixed(2)} Deposit` : 'Confirm Booking'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Clock, DollarSign, Loader2 } from 'lucide-react';
import { usePublicServices, usePublicBusinessInfo, PublicService } from '../../hooks/useBooking';
interface ServiceSelectionProps {
selectedService: PublicService | null;
onSelect: (service: PublicService) => void;
}
export const ServiceSelection: React.FC<ServiceSelectionProps> = ({ selectedService, onSelect }) => {
const { data: services, isLoading: servicesLoading } = usePublicServices();
const { data: businessInfo, isLoading: businessLoading } = usePublicBusinessInfo();
const isLoading = servicesLoading || businessLoading;
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
</div>
);
}
const heading = businessInfo?.service_selection_heading || 'Choose your experience';
const subheading = businessInfo?.service_selection_subheading || 'Select a service to begin your booking.';
// Get first photo as image, or use a placeholder
const getServiceImage = (service: PublicService): string | null => {
if (service.photos && service.photos.length > 0) {
return service.photos[0];
}
return null;
};
// Format price from cents to dollars
const formatPrice = (cents: number): string => {
return (cents / 100).toFixed(2);
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{heading}</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">{subheading}</p>
</div>
{(!services || services.length === 0) && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No services available at this time.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{services?.map((service) => {
const image = getServiceImage(service);
const hasImage = !!image;
return (
<div
key={service.id}
onClick={() => onSelect(service)}
className={`
relative overflow-hidden rounded-xl border-2 transition-all duration-200 cursor-pointer group
${selectedService?.id === service.id
? 'border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/20 ring-2 ring-indigo-600 dark:ring-indigo-400 ring-offset-2 dark:ring-offset-gray-900'
: 'border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-lg bg-white dark:bg-gray-800'}
`}
>
<div className="flex h-full min-h-[140px]">
{hasImage && (
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
<img
src={image}
alt={service.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
<div className={`${hasImage ? 'w-2/3' : 'w-full'} p-5 flex flex-col justify-between`}>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{service.name}
</h3>
{service.description && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{service.description}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4 mr-1.5" />
{service.duration} mins
</div>
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
<DollarSign className="w-4 h-4" />
{formatPrice(service.price_cents)}
</div>
</div>
{service.deposit_amount_cents && service.deposit_amount_cents > 0 && (
<div className="mt-2 text-xs text-indigo-600 dark:text-indigo-400 font-medium">
Deposit required: ${formatPrice(service.deposit_amount_cents)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Check } from 'lucide-react';
interface StepsProps {
currentStep: number;
}
const steps = [
{ id: 1, name: 'Service' },
{ id: 2, name: 'Date & Time' },
{ id: 3, name: 'Account' },
{ id: 4, name: 'Payment' },
{ id: 5, name: 'Done' },
];
export const Steps: React.FC<StepsProps> = ({ currentStep }) => {
return (
<nav aria-label="Progress">
<ol role="list" className="flex items-center">
{steps.map((step, stepIdx) => (
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pr-8 sm:pr-20' : ''} relative`}>
{step.id < currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-indigo-600 dark:bg-indigo-500" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 dark:bg-indigo-500 hover:bg-indigo-700 dark:hover:bg-indigo-600">
<Check className="h-5 w-5 text-white" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : step.id === currentStep ? (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800" aria-current="step">
<span className="h-2.5 w-2.5 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
) : (
<>
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="h-0.5 w-full bg-gray-200 dark:bg-gray-700" />
</div>
<a href="#" className="group relative flex h-8 w-8 items-center justify-center rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500">
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300 dark:group-hover:bg-gray-600" aria-hidden="true" />
<span className="sr-only">{step.name}</span>
</a>
</>
)}
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2 w-max text-xs font-medium text-gray-500 dark:text-gray-400">
{step.name}
</div>
</li>
))}
</ol>
</nav>
);
};

View File

@@ -0,0 +1,61 @@
import { Service, TimeSlot } from './types';
// Mock services for booking flow
// TODO: In production, these should be fetched from the API
export const SERVICES: Service[] = [
{
id: 's1',
name: 'Rejuvenating Facial',
description: 'A 60-minute deep cleansing and hydrating facial treatment.',
durationMin: 60,
price: 120,
deposit: 30,
category: 'Skincare',
image: 'https://picsum.photos/400/300?random=1'
},
{
id: 's2',
name: 'Deep Tissue Massage',
description: 'Therapeutic massage focusing on realigning deeper layers of muscles.',
durationMin: 90,
price: 150,
deposit: 50,
category: 'Massage',
image: 'https://picsum.photos/400/300?random=2'
},
{
id: 's3',
name: 'Executive Haircut',
description: 'Precision haircut with wash, style, and hot towel finish.',
durationMin: 45,
price: 65,
deposit: 15,
category: 'Hair',
image: 'https://picsum.photos/400/300?random=3'
},
{
id: 's4',
name: 'Full Body Scrub',
description: 'Exfoliating treatment to remove dead skin cells and improve circulation.',
durationMin: 60,
price: 110,
deposit: 25,
category: 'Body',
image: 'https://picsum.photos/400/300?random=4'
}
];
// Mock time slots
// TODO: In production, these should be fetched from the availability API
export const TIME_SLOTS: TimeSlot[] = [
{ id: 't1', time: '09:00 AM', available: true },
{ id: 't2', time: '10:00 AM', available: true },
{ id: 't3', time: '11:00 AM', available: false },
{ id: 't4', time: '01:00 PM', available: true },
{ id: 't5', time: '02:00 PM', available: true },
{ id: 't6', time: '03:00 PM', available: true },
{ id: 't7', time: '04:00 PM', available: false },
{ id: 't8', time: '05:00 PM', available: true },
];
export const APP_NAME = "SmoothSchedule";

View File

@@ -0,0 +1,36 @@
export interface Service {
id: string;
name: string;
description: string;
durationMin: number;
price: number;
deposit: number;
image: string;
category: string;
}
export interface User {
id: string;
name: string;
email: string;
}
export interface TimeSlot {
id: string;
time: string; // "09:00 AM"
available: boolean;
}
export interface BookingState {
step: number;
service: Service | null;
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
}

View File

@@ -83,7 +83,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
openTickets.slice(0, 5).map((ticket) => (
<Link
key={ticket.id}
to="/tickets"
to="/dashboard/tickets"
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
>
<div className="flex items-start justify-between gap-2">
@@ -110,7 +110,7 @@ const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
{openTickets.length > 5 && (
<Link
to="/tickets"
to="/dashboard/tickets"
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
>
View all {openTickets.length} tickets

View File

@@ -841,8 +841,17 @@ describe('ChartWidget', () => {
it('should support different color schemes', () => {
const colors = ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6'];
const { rerender } = render(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[0]}
/>
);
colors.forEach((color) => {
const { container, rerender } = render(
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
@@ -853,17 +862,6 @@ describe('ChartWidget', () => {
const bar = screen.getByTestId('bar');
expect(bar).toHaveAttribute('data-fill', color);
if (color !== colors[colors.length - 1]) {
rerender(
<ChartWidget
title="Revenue"
data={mockChartData}
type="bar"
color={colors[colors.indexOf(color) + 1]}
/>
);
}
});
});

View File

@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
formatPrice,
PublicPlanVersion,
} from '../../hooks/usePublicPlans';
interface DynamicPricingCardsProps {
className?: string;
}
const DynamicPricingCards: React.FC<DynamicPricingCardsProps> = ({ className = '' }) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans) {
return (
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
{t('marketing.pricing.loadError', 'Unable to load pricing. Please try again later.')}
</div>
);
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={className}>
{/* Billing Toggle */}
<div className="flex justify-center mb-12">
<div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg inline-flex">
<button
onClick={() => setBillingPeriod('monthly')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'monthly'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.monthly', 'Monthly')}
</button>
<button
onClick={() => setBillingPeriod('annual')}
className={`px-6 py-2 rounded-md text-sm font-medium transition-colors ${
billingPeriod === 'annual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{t('marketing.pricing.annual', 'Annual')}
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-semibold">
{t('marketing.pricing.savePercent', 'Save ~17%')}
</span>
</button>
</div>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{sortedPlans.map((planVersion) => (
<PlanCard
key={planVersion.id}
planVersion={planVersion}
billingPeriod={billingPeriod}
/>
))}
</div>
</div>
);
};
interface PlanCardProps {
planVersion: PublicPlanVersion;
billingPeriod: 'monthly' | 'annual';
}
const PlanCard: React.FC<PlanCardProps> = ({ planVersion, billingPeriod }) => {
const { t } = useTranslation();
const { plan, is_most_popular, show_price, marketing_features, trial_days } = planVersion;
const price =
billingPeriod === 'annual'
? planVersion.price_yearly_cents
: planVersion.price_monthly_cents;
const isEnterprise = !show_price || plan.code === 'enterprise';
const isFree = price === 0 && plan.code === 'free';
// Determine CTA
const ctaLink = isEnterprise ? '/contact' : `/signup?plan=${plan.code}`;
const ctaText = isEnterprise
? t('marketing.pricing.contactSales', 'Contact Sales')
: isFree
? t('marketing.pricing.getStartedFree', 'Get Started Free')
: t('marketing.pricing.startTrial', 'Start Free Trial');
if (is_most_popular) {
return (
<div className="relative flex flex-col p-6 bg-brand-600 rounded-2xl shadow-xl shadow-brand-600/20 transform lg:scale-105 z-10">
{/* Most Popular Badge */}
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-brand-500 text-white text-xs font-semibold rounded-full whitespace-nowrap">
{t('marketing.pricing.mostPopular', 'Most Popular')}
</div>
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-white mb-1">{plan.name}</h3>
<p className="text-brand-100 text-sm">{plan.description}</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-white">
{formatPrice(price)}
</span>
<span className="text-brand-200 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-100">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-200 flex-shrink-0 mt-0.5" />
<span className="text-white text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className="block w-full py-3 px-4 text-center text-sm font-semibold text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-colors"
>
{ctaText}
</Link>
</div>
);
}
return (
<div className="relative flex flex-col p-6 bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
{plan.name}
</h3>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{plan.description}
</p>
</div>
{/* Price */}
<div className="mb-4">
{isEnterprise ? (
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{t('marketing.pricing.custom', 'Custom')}
</span>
) : (
<>
<span className="text-4xl font-bold text-gray-900 dark:text-white">
{formatPrice(price)}
</span>
<span className="text-gray-500 dark:text-gray-400 ml-1 text-sm">
{billingPeriod === 'annual'
? t('marketing.pricing.perYear', '/year')
: t('marketing.pricing.perMonth', '/month')}
</span>
</>
)}
{trial_days > 0 && !isFree && (
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
{t('marketing.pricing.trialDays', '{{days}}-day free trial', {
days: trial_days,
})}
</div>
)}
{isFree && (
<div className="mt-1 text-xs text-green-600 dark:text-green-400">
{t('marketing.pricing.freeForever', 'Free forever')}
</div>
)}
</div>
{/* Features */}
<ul className="flex-1 space-y-2 mb-6">
{marketing_features.map((feature, index) => (
<li key={index} className="flex items-start gap-2">
<Check className="h-4 w-4 text-brand-600 dark:text-brand-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-700 dark:text-gray-300 text-sm">{feature}</span>
</li>
))}
</ul>
{/* CTA */}
<Link
to={ctaLink}
className={`block w-full py-3 px-4 text-center text-sm font-semibold rounded-xl transition-colors ${
isFree
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-600'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}
>
{ctaText}
</Link>
</div>
);
};
export default DynamicPricingCards;

View File

@@ -0,0 +1,251 @@
import React from 'react';
import { Check, X, Minus, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
usePublicPlans,
PublicPlanVersion,
getPlanFeatureValue,
formatLimit,
} from '../../hooks/usePublicPlans';
// Feature categories for the comparison table
const FEATURE_CATEGORIES = [
{
key: 'limits',
features: [
{ code: 'max_users', label: 'Team members' },
{ code: 'max_resources', label: 'Resources' },
{ code: 'max_locations', label: 'Locations' },
{ code: 'max_services', label: 'Services' },
{ code: 'max_customers', label: 'Customers' },
{ code: 'max_appointments_per_month', label: 'Appointments/month' },
],
},
{
key: 'communication',
features: [
{ code: 'email_enabled', label: 'Email notifications' },
{ code: 'max_email_per_month', label: 'Emails/month' },
{ code: 'sms_enabled', label: 'SMS reminders' },
{ code: 'max_sms_per_month', label: 'SMS/month' },
{ code: 'masked_calling_enabled', label: 'Masked calling' },
],
},
{
key: 'booking',
features: [
{ code: 'online_booking', label: 'Online booking' },
{ code: 'recurring_appointments', label: 'Recurring appointments' },
{ code: 'payment_processing', label: 'Accept payments' },
{ code: 'mobile_app_access', label: 'Mobile app' },
],
},
{
key: 'integrations',
features: [
{ code: 'integrations_enabled', label: 'Third-party integrations' },
{ code: 'api_access', label: 'API access' },
{ code: 'max_api_calls_per_day', label: 'API calls/day' },
],
},
{
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
{ code: 'custom_branding', label: 'Custom branding' },
{ code: 'remove_branding', label: 'Remove "Powered by"' },
{ code: 'white_label', label: 'White label' },
],
},
{
key: 'enterprise',
features: [
{ code: 'multi_location', label: 'Multi-location management' },
{ code: 'team_permissions', label: 'Team permissions' },
{ code: 'audit_logs', label: 'Audit logs' },
{ code: 'advanced_reporting', label: 'Advanced analytics' },
],
},
{
key: 'support',
features: [
{ code: 'priority_support', label: 'Priority support' },
{ code: 'dedicated_account_manager', label: 'Dedicated account manager' },
{ code: 'sla_guarantee', label: 'SLA guarantee' },
],
},
{
key: 'storage',
features: [
{ code: 'max_storage_mb', label: 'File storage' },
],
},
];
interface FeatureComparisonTableProps {
className?: string;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
className = '',
}) => {
const { t } = useTranslation();
const { data: plans, isLoading, error } = usePublicPlans();
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
if (error || !plans || plans.length === 0) {
return null;
}
// Sort plans by display_order
const sortedPlans = [...plans].sort(
(a, b) => a.plan.display_order - b.plan.display_order
);
return (
<div className={`overflow-x-auto ${className}`}>
<table className="w-full min-w-[800px]">
{/* Header */}
<thead>
<tr>
<th className="text-left py-4 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 w-64">
{t('marketing.pricing.featureComparison.features', 'Features')}
</th>
{sortedPlans.map((planVersion) => (
<th
key={planVersion.id}
className={`text-center py-4 px-4 text-sm font-semibold border-b border-gray-200 dark:border-gray-700 ${
planVersion.is_most_popular
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/20'
: 'text-gray-900 dark:text-white'
}`}
>
{planVersion.plan.name}
</th>
))}
</tr>
</thead>
<tbody>
{FEATURE_CATEGORIES.map((category) => (
<React.Fragment key={category.key}>
{/* Category Header */}
<tr>
<td
colSpan={sortedPlans.length + 1}
className="py-3 px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50"
>
{t(
`marketing.pricing.featureComparison.categories.${category.key}`,
category.key.charAt(0).toUpperCase() + category.key.slice(1)
)}
</td>
</tr>
{/* Features */}
{category.features.map((feature) => (
<tr
key={feature.code}
className="border-b border-gray-100 dark:border-gray-800"
>
<td className="py-3 px-4 text-sm text-gray-700 dark:text-gray-300">
{t(
`marketing.pricing.featureComparison.features.${feature.code}`,
feature.label
)}
</td>
{sortedPlans.map((planVersion) => (
<td
key={`${planVersion.id}-${feature.code}`}
className={`py-3 px-4 text-center ${
planVersion.is_most_popular
? 'bg-brand-50/50 dark:bg-brand-900/10'
: ''
}`}
>
<FeatureValue
planVersion={planVersion}
featureCode={feature.code}
/>
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
);
};
interface FeatureValueProps {
planVersion: PublicPlanVersion;
featureCode: string;
}
const FeatureValue: React.FC<FeatureValueProps> = ({
planVersion,
featureCode,
}) => {
const value = getPlanFeatureValue(planVersion, featureCode);
// Handle null/undefined - feature not set
if (value === null || value === undefined) {
return (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Boolean feature
if (typeof value === 'boolean') {
return value ? (
<Check className="w-5 h-5 text-green-500 mx-auto" />
) : (
<X className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />
);
}
// Integer feature (limit)
if (typeof value === 'number') {
// Special handling for storage (convert MB to GB if > 1000)
if (featureCode === 'max_storage_mb') {
if (value === 0) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
Unlimited
</span>
);
}
if (value >= 1000) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{(value / 1000).toFixed(0)} GB
</span>
);
}
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{value} MB
</span>
);
}
// Regular limit display
return (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formatLimit(value)}
</span>
);
}
// Fallback
return <Minus className="w-5 h-5 text-gray-300 dark:text-gray-600 mx-auto" />;
};
export default FeatureComparisonTable;

View File

@@ -139,7 +139,7 @@ describe('CodeBlock', () => {
expect(checkIcon).toBeInTheDocument();
});
it('reverts to copy icon after 2 seconds', () => {
it('reverts to copy icon after 2 seconds', async () => {
const code = 'test code';
mockWriteText.mockResolvedValue(undefined);
@@ -148,14 +148,18 @@ describe('CodeBlock', () => {
const copyButton = screen.getByRole('button', { name: /copy code/i });
// Click to copy
fireEvent.click(copyButton);
await act(async () => {
fireEvent.click(copyButton);
});
// Should show Check icon
let checkIcon = container.querySelector('.text-green-400');
expect(checkIcon).toBeInTheDocument();
// Fast-forward 2 seconds using act to wrap state updates
vi.advanceTimersByTime(2000);
await act(async () => {
vi.advanceTimersByTime(2000);
});
// Should revert to Copy icon (check icon should be gone)
checkIcon = container.querySelector('.text-green-400');

View File

@@ -435,7 +435,9 @@ describe('Navbar', () => {
});
it('should close mobile menu on route change', () => {
// Test that mobile menu state resets when component receives new location
// Test that clicking a navigation link closes the mobile menu
// In production, clicking a link triggers a route change which closes the menu via useEffect
// In tests with MemoryRouter, the route change happens and the useEffect fires
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
wrapper: createWrapper('/'),
});
@@ -447,14 +449,12 @@ describe('Navbar', () => {
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-96');
// Click a navigation link (simulates route change behavior)
// Click a navigation link - this triggers navigation to /features
// The useEffect with location.pathname dependency should close the menu
const featuresLink = screen.getAllByRole('link', { name: 'Features' })[1]; // Mobile menu link
fireEvent.click(featuresLink);
// The useEffect with location.pathname dependency should close the menu
// In actual usage, clicking a link triggers navigation which changes location.pathname
// For this test, we verify the menu can be manually closed
fireEvent.click(menuButton);
// After navigation, menu should be closed
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
expect(mobileMenuContainer).toHaveClass('max-h-0');
});

View File

@@ -47,6 +47,7 @@ interface SidebarItemProps {
exact?: boolean;
disabled?: boolean;
badge?: string | number;
badgeElement?: React.ReactNode;
variant?: 'default' | 'settings';
locked?: boolean;
}
@@ -62,6 +63,7 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
exact = false,
disabled = false,
badge,
badgeElement,
variant = 'default',
locked = false,
}) => {
@@ -97,8 +99,10 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
<div className={className} title={label}>
<Icon size={20} className="shrink-0" />
{!isCollapsed && <span className="flex-1">{label}</span>}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
{(badge || badgeElement) && !isCollapsed && (
badgeElement || (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10">{badge}</span>
)
)}
</div>
);
@@ -113,10 +117,12 @@ export const SidebarItem: React.FC<SidebarItemProps> = ({
{locked && <Lock size={12} className="opacity-60" />}
</span>
)}
{badge && !isCollapsed && (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
{(badge || badgeElement) && !isCollapsed && (
badgeElement || (
<span className="px-2 py-0.5 text-xs rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/50 dark:text-brand-400">
{badge}
</span>
)
)}
</Link>
);
@@ -256,6 +262,7 @@ interface SettingsSidebarItemProps {
label: string;
description?: string;
locked?: boolean;
badgeElement?: React.ReactNode;
}
/**
@@ -267,6 +274,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
label,
description,
locked = false,
badgeElement,
}) => {
const location = useLocation();
const isActive = location.pathname === to || location.pathname.startsWith(to + '/');
@@ -289,6 +297,7 @@ export const SettingsSidebarItem: React.FC<SettingsSidebarItemProps> = ({
{locked && (
<Lock size={12} className="text-gray-400 dark:text-gray-500" />
)}
{badgeElement}
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-500 truncate">

View File

@@ -0,0 +1,312 @@
/**
* DynamicFeaturesEditor
*
* A dynamic component that loads features from the billing system API
* and renders them as toggles/inputs for editing business permissions.
*
* This is the DYNAMIC version that gets features from the billing catalog,
* which is the single source of truth. When you add a new feature to the
* billing system, it automatically appears here.
*/
import React, { useMemo } from 'react';
import { Key, AlertCircle } from 'lucide-react';
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
export interface DynamicFeaturesEditorProps {
/**
* Current feature values mapped by tenant_field_name
* For booleans: { can_use_sms_reminders: true, can_api_access: false, ... }
* For integers: { max_users: 10, max_resources: 5, ... }
*/
values: Record<string, boolean | number | null>;
/**
* Callback when a feature value changes
* @param fieldName - The tenant_field_name of the feature
* @param value - The new value (boolean for toggles, number for limits)
*/
onChange: (fieldName: string, value: boolean | number | null) => void;
/**
* Optional: Only show features in these categories
*/
categories?: BillingFeature['category'][];
/**
* Optional: Only show boolean or integer features
*/
featureType?: 'boolean' | 'integer';
/**
* Optional: Exclude features by code
*/
excludeCodes?: string[];
/**
* Show section header (default: true)
*/
showHeader?: boolean;
/**
* Custom header title
*/
headerTitle?: string;
/**
* Show descriptions under labels (default: false)
*/
showDescriptions?: boolean;
/**
* Number of columns (default: 3)
*/
columns?: 2 | 3 | 4;
}
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
values,
onChange,
categories,
featureType,
excludeCodes = [],
showHeader = true,
headerTitle = 'Features & Permissions',
showDescriptions = false,
columns = 3,
}) => {
const { data: features, isLoading, error } = useBillingFeatures();
// Debug logging
console.log('[DynamicFeaturesEditor] Features:', features?.length, 'Loading:', isLoading, 'Error:', error);
// Filter and group features
const groupedFeatures = useMemo(() => {
if (!features) {
console.log('[DynamicFeaturesEditor] No features data');
return {};
}
// Filter features
const filtered = features.filter(f => {
if (excludeCodes.includes(f.code)) return false;
if (categories && !categories.includes(f.category)) return false;
if (featureType && f.feature_type !== featureType) return false;
if (!f.is_overridable) return false; // Skip non-overridable features
if (!f.tenant_field_name) return false; // Skip features without tenant field
return true;
});
console.log('[DynamicFeaturesEditor] Filtered features:', filtered.length, 'featureType:', featureType);
// Group by category
const groups: Record<string, BillingFeature[]> = {};
for (const feature of filtered) {
if (!groups[feature.category]) {
groups[feature.category] = [];
}
groups[feature.category].push(feature);
}
// Sort features within each category by display_order
for (const category of Object.keys(groups)) {
groups[category].sort((a, b) => a.display_order - b.display_order);
}
return groups;
}, [features, categories, featureType, excludeCodes]);
// Sort categories by their order
const sortedCategories = useMemo(() => {
return Object.keys(groupedFeatures).sort(
(a, b) => (FEATURE_CATEGORY_META[a as BillingFeature['category']]?.order ?? 99) -
(FEATURE_CATEGORY_META[b as BillingFeature['category']]?.order ?? 99)
) as BillingFeature['category'][];
}, [groupedFeatures]);
// Check if a dependent feature should be disabled
const isDependencyDisabled = (feature: BillingFeature): boolean => {
if (!feature.depends_on_code) return false;
const parentFeature = features?.find(f => f.code === feature.depends_on_code);
if (!parentFeature) return false;
const parentValue = values[parentFeature.tenant_field_name];
return !parentValue;
};
// Handle value change
const handleChange = (feature: BillingFeature, newValue: boolean | number | null) => {
onChange(feature.tenant_field_name, newValue);
// If disabling a parent feature, also disable dependents
if (feature.feature_type === 'boolean' && !newValue) {
const dependents = features?.filter(f => f.depends_on_code === feature.code) ?? [];
for (const dep of dependents) {
if (values[dep.tenant_field_name]) {
onChange(dep.tenant_field_name, false);
}
}
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
};
if (isLoading) {
return (
<div className="space-y-4">
{showHeader && (
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
)}
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
<div className="grid grid-cols-3 gap-3">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="h-12 bg-gray-100 dark:bg-gray-800 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{showHeader && (
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
)}
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle size={16} />
<span className="text-sm">Failed to load features from billing system</span>
</div>
</div>
);
}
return (
<div className="space-y-4">
{showHeader && (
<>
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available. Features are loaded from the billing system.
</p>
</>
)}
{sortedCategories.map(category => {
const categoryFeatures = groupedFeatures[category];
if (!categoryFeatures || categoryFeatures.length === 0) return null;
const categoryMeta = FEATURE_CATEGORY_META[category];
return (
<div key={category}>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{categoryMeta?.label || category}
</h4>
<div className={`grid ${gridCols[columns]} gap-3`}>
{categoryFeatures.map(feature => {
const isDisabled = isDependencyDisabled(feature);
const currentValue = values[feature.tenant_field_name];
if (feature.feature_type === 'boolean') {
const isChecked = currentValue === true;
return (
<label
key={feature.code}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(feature, e.target.checked)}
disabled={isDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
{feature.name}
</span>
{showDescriptions && feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{feature.description}
</span>
)}
</div>
</label>
);
}
// Integer feature (limit)
const intValue = typeof currentValue === 'number' ? currentValue : 0;
const isUnlimited = currentValue === null || currentValue === -1;
return (
<div
key={feature.code}
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<label className="text-sm text-gray-700 dark:text-gray-300 block mb-1">
{feature.name}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="-1"
value={isUnlimited ? -1 : intValue}
onChange={(e) => {
const val = parseInt(e.target.value);
handleChange(feature, val === -1 ? null : val);
}}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500"
/>
</div>
{showDescriptions && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-1">
{feature.description} (-1 = unlimited)
</span>
)}
</div>
);
})}
</div>
{/* Show dependency hint for plugins category */}
{category === 'plugins' && (
(() => {
const pluginsFeature = categoryFeatures.find(f => f.code === 'can_use_plugins');
if (pluginsFeature && !values[pluginsFeature.tenant_field_name]) {
return (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable "Use Plugins" to allow dependent features
</p>
);
}
return null;
})()
)}
</div>
);
})}
</div>
);
};
export default DynamicFeaturesEditor;

View File

@@ -0,0 +1,531 @@
/**
* FeaturesPermissionsEditor
*
* A unified component for editing features and permissions.
* Used by both subscription plan editing (PlatformSettings) and
* individual business editing (BusinessEditModal).
*
* Supports two modes:
* - 'plan': For editing subscription plan permissions (uses plan-style keys)
* - 'business': For editing individual business permissions (uses tenant-style keys)
*/
import React from 'react';
import { Key } from 'lucide-react';
/**
* Permission definition with metadata
*/
interface PermissionDefinition {
key: string;
planKey?: string; // Key used in subscription plan permissions JSON
businessKey?: string; // Key used in tenant/business model fields
label: string;
description?: string;
category: PermissionCategory;
dependsOn?: string; // Key of permission this depends on
}
type PermissionCategory =
| 'payments'
| 'communication'
| 'customization'
| 'plugins'
| 'advanced'
| 'enterprise'
| 'scheduling';
/**
* All available permissions with their mappings
*/
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
// Payments & Revenue
{
key: 'can_accept_payments',
planKey: 'can_accept_payments',
businessKey: 'can_accept_payments',
label: 'Online Payments',
description: 'Accept payments via Stripe Connect',
category: 'payments',
},
{
key: 'can_process_refunds',
planKey: 'can_process_refunds',
businessKey: 'can_process_refunds',
label: 'Process Refunds',
description: 'Issue refunds for payments',
category: 'payments',
},
{
key: 'can_create_packages',
planKey: 'can_create_packages',
businessKey: 'can_create_packages',
label: 'Service Packages',
description: 'Create and sell service packages',
category: 'payments',
},
{
key: 'can_use_pos',
planKey: 'can_use_pos',
businessKey: 'can_use_pos',
label: 'POS System',
description: 'Point of sale for in-person payments',
category: 'payments',
},
// Communication
{
key: 'sms_reminders',
planKey: 'sms_reminders',
businessKey: 'can_use_sms_reminders',
label: 'SMS Reminders',
description: 'Send SMS appointment reminders',
category: 'communication',
},
{
key: 'masked_calling',
planKey: 'can_use_masked_phone_numbers',
businessKey: 'can_use_masked_phone_numbers',
label: 'Masked Calling',
description: 'Use masked phone numbers for privacy',
category: 'communication',
},
{
key: 'email_templates',
planKey: 'can_use_email_templates',
businessKey: 'can_use_email_templates',
label: 'Email Templates',
description: 'Custom email templates for communications',
category: 'communication',
},
// Customization
{
key: 'custom_booking_page',
planKey: 'can_customize_booking_page',
businessKey: 'can_customize_booking_page',
label: 'Custom Booking Page',
description: 'Customize the public booking page',
category: 'customization',
},
{
key: 'custom_domain',
planKey: 'can_use_custom_domain',
businessKey: 'can_use_custom_domain',
label: 'Custom Domains',
description: 'Use your own domain for booking',
category: 'customization',
},
{
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'White Labelling',
description: 'Remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
category: 'plugins',
},
{
key: 'tasks',
planKey: 'can_use_tasks',
businessKey: 'can_use_tasks',
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
category: 'plugins',
dependsOn: 'plugins',
},
// Advanced Features
{
key: 'api_access',
planKey: 'can_api_access',
businessKey: 'can_api_access',
label: 'API Access',
description: 'Access REST API for integrations',
category: 'advanced',
},
{
key: 'webhooks',
planKey: 'can_use_webhooks',
businessKey: 'can_use_webhooks',
label: 'Webhooks',
description: 'Receive webhook notifications',
category: 'advanced',
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
category: 'advanced',
},
{
key: 'export_data',
planKey: 'can_export_data',
businessKey: 'can_export_data',
label: 'Data Export',
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
businessKey: 'advanced_reporting',
label: 'Advanced Analytics',
description: 'Detailed reporting and analytics',
category: 'advanced',
},
{
key: 'contracts',
planKey: 'contracts_enabled',
businessKey: 'can_use_contracts',
label: 'Contracts',
description: 'Create and manage e-signature contracts',
category: 'advanced',
},
{
key: 'mobile_app',
planKey: 'can_use_mobile_app',
businessKey: 'can_use_mobile_app',
label: 'Mobile App',
description: 'Access via mobile application',
category: 'advanced',
},
// Enterprise & Security
{
key: 'manage_oauth',
planKey: 'can_manage_oauth_credentials',
businessKey: 'can_manage_oauth_credentials',
label: 'Manage OAuth',
description: 'Configure custom OAuth credentials',
category: 'enterprise',
},
{
key: 'require_2fa',
planKey: 'can_require_2fa',
businessKey: 'can_require_2fa',
label: 'Require 2FA',
description: 'Enforce two-factor authentication',
category: 'enterprise',
},
{
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
category: 'enterprise',
},
{
key: 'priority_support',
planKey: 'priority_support',
businessKey: 'priority_support',
label: 'Priority Support',
description: 'Faster response times',
category: 'enterprise',
},
{
key: 'dedicated_support',
planKey: 'dedicated_support',
businessKey: 'dedicated_support',
label: 'Dedicated Support',
description: 'Dedicated account manager',
category: 'enterprise',
},
// Scheduling
{
key: 'repeated_events',
planKey: 'can_book_repeated_events',
businessKey: 'can_book_repeated_events',
label: 'Recurring Events',
description: 'Schedule recurring appointments',
category: 'scheduling',
},
];
/**
* Category metadata for display
*/
const CATEGORY_META: Record<PermissionCategory, { label: string; order: number }> = {
payments: { label: 'Payments & Revenue', order: 1 },
communication: { label: 'Communication', order: 2 },
customization: { label: 'Customization', order: 3 },
plugins: { label: 'Plugins & Automation', order: 4 },
advanced: { label: 'Advanced Features', order: 5 },
scheduling: { label: 'Scheduling', order: 6 },
enterprise: { label: 'Enterprise & Security', order: 7 },
};
export type EditorMode = 'plan' | 'business';
export interface FeaturesPermissionsEditorProps {
/**
* Mode determines which keys are used and which permissions are shown
*/
mode: EditorMode;
/**
* Current permission values
* For 'plan' mode: the permissions object from subscription plan
* For 'business' mode: flat object with tenant field names
*/
values: Record<string, boolean>;
/**
* Callback when a permission changes
*/
onChange: (key: string, value: boolean) => void;
/**
* Optional: Limit which categories to show
*/
categories?: PermissionCategory[];
/**
* Optional: Limit which permissions to show by key
*/
includeOnly?: string[];
/**
* Optional: Hide specific permissions
*/
exclude?: string[];
/**
* Number of columns in the grid (default: 3)
*/
columns?: 2 | 3 | 4;
/**
* Show section header
*/
showHeader?: boolean;
/**
* Custom header title
*/
headerTitle?: string;
/**
* Show descriptions under labels
*/
showDescriptions?: boolean;
}
/**
* Get the appropriate key for a permission based on mode
*/
export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string {
if (mode === 'plan') {
return def.planKey || def.key;
}
return def.businessKey || def.key;
}
/**
* Convert permissions from one mode to another
*/
export function convertPermissions(
values: Record<string, boolean>,
fromMode: EditorMode,
toMode: EditorMode
): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const def of PERMISSION_DEFINITIONS) {
const fromKey = getPermissionKey(def, fromMode);
const toKey = getPermissionKey(def, toMode);
if (fromKey in values) {
result[toKey] = values[fromKey];
}
}
return result;
}
/**
* Get permission value from values object
*/
function getPermissionValue(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
const key = getPermissionKey(def, mode);
return values[key] ?? false;
}
/**
* Check if a dependent permission should be disabled
*/
function isDependencyDisabled(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
if (!def.dependsOn) return false;
const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn);
if (!parentDef) return false;
return !getPermissionValue(values, parentDef, mode);
}
const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
mode,
values,
onChange,
categories,
includeOnly,
exclude = [],
columns = 3,
showHeader = true,
headerTitle = 'Features & Permissions',
showDescriptions = false,
}) => {
// Filter permissions based on props
const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => {
if (exclude.includes(def.key)) return false;
if (includeOnly && !includeOnly.includes(def.key)) return false;
if (categories && !categories.includes(def.category)) return false;
return true;
});
// Group by category
const groupedPermissions = filteredPermissions.reduce((acc, def) => {
if (!acc[def.category]) {
acc[def.category] = [];
}
acc[def.category].push(def);
return acc;
}, {} as Record<PermissionCategory, PermissionDefinition[]>);
// Sort categories by order
const sortedCategories = Object.keys(groupedPermissions).sort(
(a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order
) as PermissionCategory[];
const handleChange = (def: PermissionDefinition, checked: boolean) => {
const key = getPermissionKey(def, mode);
onChange(key, checked);
// If disabling a parent permission, also disable dependents
if (!checked) {
const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key);
for (const dep of dependents) {
const depKey = getPermissionKey(dep, mode);
if (values[depKey]) {
onChange(depKey, false);
}
}
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
};
return (
<div className="space-y-4">
{showHeader && (
<>
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available.
</p>
</>
)}
{sortedCategories.map(category => (
<div key={category}>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{CATEGORY_META[category].label}
</h4>
<div className={`grid ${gridCols[columns]} gap-3`}>
{groupedPermissions[category].map(def => {
const isChecked = getPermissionValue(values, def, mode);
const isDisabled = isDependencyDisabled(values, def, mode);
const key = getPermissionKey(def, mode);
return (
<label
key={def.key}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(def, e.target.checked)}
disabled={isDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
{def.label}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{def.description}
</span>
)}
</div>
</label>
);
})}
</div>
{/* Show dependency hint for plugins category */}
{category === 'plugins' && !getPermissionValue(
values,
PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!,
mode
) && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
</p>
)}
</div>
))}
</div>
);
};
export default FeaturesPermissionsEditor;

View File

@@ -0,0 +1,149 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
DollarSign,
Image as ImageIcon,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { Service, Business } from '../../types';
import Badge from '../ui/Badge';
interface CustomerPreviewProps {
service: Service | null; // Null when creating new
business: Business;
previewData?: Partial<Service>; // Live form data
}
export const CustomerPreview: React.FC<CustomerPreviewProps> = ({
service,
business,
previewData
}) => {
const { t } = useTranslation();
// Merge existing service data with live form preview
const data = {
...service,
...previewData,
price: previewData?.price ?? service?.price ?? 0,
name: previewData?.name || service?.name || 'New Service',
description: previewData?.description || service?.description || 'Service description will appear here...',
durationMinutes: previewData?.durationMinutes ?? service?.durationMinutes ?? 30,
photos: previewData?.photos ?? service?.photos ?? [],
};
// Get the first photo for the cover image
const coverPhoto = data.photos && data.photos.length > 0 ? data.photos[0] : null;
const formatPrice = (price: number | string) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numPrice);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Customer Preview
</h3>
<Badge variant="info" size="sm">Live Preview</Badge>
</div>
{/* Lumina-style Horizontal Card */}
<div className="relative overflow-hidden rounded-xl border-2 border-brand-600 bg-brand-50/50 dark:bg-brand-900/20 ring-2 ring-brand-600 ring-offset-2 dark:ring-offset-gray-900 transition-all duration-200">
<div className="flex h-full min-h-[180px]">
{/* Image Section - 1/3 width */}
<div className="w-1/3 bg-gray-100 dark:bg-gray-700 relative">
{coverPhoto ? (
<img
src={coverPhoto}
alt={data.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
background: `linear-gradient(135deg, var(--color-brand-600, ${business.primaryColor || '#2563eb'}), var(--color-brand-400, ${business.secondaryColor || '#0ea5e9'}))`
}}
>
<ImageIcon className="w-12 h-12 text-white/30" />
</div>
)}
</div>
{/* Content Section - 2/3 width */}
<div className="w-2/3 p-5 flex flex-col justify-between">
<div>
{/* Category Badge */}
<div className="flex justify-between items-start">
<span className="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/50 px-2.5 py-0.5 text-xs font-medium text-brand-800 dark:text-brand-300">
{data.category?.name || 'General'}
</span>
{data.variable_pricing && (
<span className="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:text-purple-300">
Variable
</span>
)}
</div>
{/* Title */}
<h3 className="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
{data.name}
</h3>
{/* Description */}
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{data.description}
</p>
</div>
{/* Bottom Info */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center text-gray-600 dark:text-gray-300">
<Clock className="w-4 h-4 mr-1.5" />
{data.durationMinutes} mins
</div>
<div className="flex items-center font-semibold text-gray-900 dark:text-white">
{data.variable_pricing ? (
<span className="text-purple-600 dark:text-purple-400">Price varies</span>
) : (
<>
<DollarSign className="w-4 h-4" />
{data.price}
</>
)}
</div>
</div>
{/* Deposit Info */}
{((data.deposit_amount && data.deposit_amount > 0) || (data.variable_pricing && data.deposit_amount)) && (
<div className="mt-2 text-xs text-brand-600 dark:text-brand-400 font-medium">
Deposit required: {formatPrice(data.deposit_amount || 0)}
</div>
)}
</div>
</div>
</div>
</div>
{/* Info Note */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 flex gap-3 items-start">
<AlertCircle size={20} className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<p className="text-sm text-blue-800 dark:text-blue-300">
This is how your service will appear to customers on your booking page.
</p>
</div>
</div>
);
};
export default CustomerPreview;

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Users,
Search,
Check,
X,
AlertCircle
} from 'lucide-react';
import { Resource } from '../../types';
import FormInput from '../ui/FormInput';
import Badge from '../ui/Badge';
interface ResourceSelectorProps {
resources: Resource[];
selectedIds: string[];
allSelected: boolean;
onChange: (ids: string[], all: boolean) => void;
}
export const ResourceSelector: React.FC<ResourceSelectorProps> = ({
resources,
selectedIds,
allSelected,
onChange
}) => {
const { t } = useTranslation();
const [search, setSearch] = React.useState('');
const filteredResources = resources.filter(r =>
r.name.toLowerCase().includes(search.toLowerCase())
);
const handleToggle = (id: string) => {
if (allSelected) {
// If switching from All to Specific, start with just this one selected?
// Or keep all others selected?
// Better UX: "All" is a special mode. If you uncheck one, you enter "Specific" mode with all-minus-one selected.
// But we don't have all IDs readily available without mapping.
// Let's assume typical toggle logic.
// Actually, if "All" is true, we should probably toggle it OFF and select just this ID?
// Or select all EXCEPT this ID?
// Let's simplify: Toggle "All Staff" switch separately.
return;
}
const newIds = selectedIds.includes(id)
? selectedIds.filter(i => i !== id)
: [...selectedIds, id];
onChange(newIds, false);
};
const handleAllToggle = () => {
if (!allSelected) {
onChange([], true);
} else {
onChange([], false); // Clear selection or keep? Let's clear for now.
}
};
return (
<div className="space-y-4">
{/* Header / All Toggle */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg text-brand-600 dark:text-brand-400">
<Users size={20} />
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">All Staff Available</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Automatically include current and future staff
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={allSelected}
onChange={handleAllToggle}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-300 dark:peer-focus:ring-brand-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-brand-600"></div>
</label>
</div>
{!allSelected && (
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
<div className="p-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search staff..."
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto p-2 bg-white dark:bg-gray-800 space-y-1">
{filteredResources.length === 0 ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
No staff found matching "{search}"
</div>
) : (
filteredResources.map(resource => (
<button
key={resource.id}
type="button"
onClick={() => handleToggle(resource.id)}
className={`w-full flex items-center justify-between p-3 rounded-lg text-left transition-colors ${
selectedIds.includes(resource.id)
? 'bg-brand-50 dark:bg-brand-900/20 text-brand-900 dark:text-brand-100'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
selectedIds.includes(resource.id)
? 'bg-brand-200 dark:bg-brand-800 text-brand-700 dark:text-brand-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{resource.name.charAt(0)}
</div>
<span>{resource.name}</span>
</div>
{selectedIds.includes(resource.id) && (
<Check size={18} className="text-brand-600 dark:text-brand-400" />
)}
</button>
))
)}
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 flex justify-between">
<span>{selectedIds.length} staff selected</span>
{selectedIds.length === 0 && (
<span className="text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle size={12} /> At least one required
</span>
)}
</div>
</div>
)}
</div>
);
};
export default ResourceSelector;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
Clock,
Users,
MoreVertical,
Pencil,
Trash2,
GripVertical,
DollarSign,
AlertCircle
} from 'lucide-react';
import { Service } from '../../types';
import Badge from '../ui/Badge';
import Card from '../ui/Card';
interface ServiceListItemProps {
service: Service;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
dragHandleProps?: any;
}
export const ServiceListItem: React.FC<ServiceListItemProps> = ({
service,
onEdit,
onDelete,
dragHandleProps
}) => {
const { t } = useTranslation();
const formatPrice = (price: number | string) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(numPrice);
};
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
return `${mins}m`;
};
return (
<div className="group relative flex items-center gap-4 bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-300 dark:hover:border-brand-700 hover:shadow-md transition-all duration-200">
{/* Drag Handle */}
<div
{...dragHandleProps}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing p-1"
>
<GripVertical size={20} />
</div>
{/* Content */}
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-4 gap-4 items-center">
{/* Name & Description */}
<div className="sm:col-span-2">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{service.name}
</h3>
{service.category && (
<Badge variant="default" size="sm" className="hidden sm:inline-flex">
{service.category.name}
</Badge>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
{service.description || 'No description provided'}
</p>
</div>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
<Clock size={16} className="text-brand-500" />
<span className="font-medium">{formatDuration(service.durationMinutes)}</span>
</div>
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-700/50 px-2.5 py-1 rounded-md">
<DollarSign size={16} className="text-green-500" />
<span className="font-medium">
{service.variable_pricing ? (
<span className="italic">Variable</span>
) : (
formatPrice(service.price)
)}
</span>
</div>
</div>
{/* Meta */}
<div className="hidden sm:flex items-center justify-end gap-3 text-xs text-gray-500">
{service.all_resources ? (
<span className="flex items-center gap-1" title="Available to all staff">
<Users size={14} /> All Staff
</span>
) : (
<span className="flex items-center gap-1" title="Restricted to specific staff">
<Users size={14} /> {service.resource_ids?.length || 0} Staff
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pl-4 border-l border-gray-100 dark:border-gray-700">
<button
onClick={() => onEdit(service)}
className="p-2 text-gray-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/30 rounded-lg transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil size={18} />
</button>
<button
onClick={() => onDelete(service)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 size={18} />
</button>
</div>
</div>
);
};
export default ServiceListItem;

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ServiceListItem from '../ServiceListItem';
import { Service } from '../../../types';
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, val: string) => val || key }),
}));
// Mock Lucide icons
vi.mock('lucide-react', () => ({
Clock: () => <span data-testid="icon-clock" />,
Users: () => <span data-testid="icon-users" />,
MoreVertical: () => <span data-testid="icon-more" />,
Pencil: () => <span data-testid="icon-pencil" />,
Trash2: () => <span data-testid="icon-trash" />,
GripVertical: () => <span data-testid="icon-grip" />,
DollarSign: () => <span data-testid="icon-dollar" />,
AlertCircle: () => <span data-testid="icon-alert" />,
}));
const mockService: Service = {
id: '1',
name: 'Test Service',
description: 'Test Description',
durationMinutes: 60,
price: 50,
variable_pricing: false,
all_resources: true,
resource_ids: [],
category: { id: 'cat1', name: 'Category 1' }
} as any; // Cast to avoid strict type checks on missing optional fields
describe('ServiceListItem', () => {
it('renders service details correctly', () => {
render(
<ServiceListItem
service={mockService}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByText('Test Service')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
expect(screen.getByText('Category 1')).toBeInTheDocument();
expect(screen.getByText('1h')).toBeInTheDocument(); // 60 mins
expect(screen.getByText('$50.00')).toBeInTheDocument();
});
it('renders variable pricing correctly', () => {
const variableService = { ...mockService, variable_pricing: true };
render(
<ServiceListItem
service={variableService}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>
);
expect(screen.getByText('Variable')).toBeInTheDocument();
});
it('triggers action callbacks', () => {
const onEdit = vi.fn();
const onDelete = vi.fn();
render(
<ServiceListItem
service={mockService}
onEdit={onEdit}
onDelete={onDelete}
/>
);
fireEvent.click(screen.getByTitle('Edit'));
expect(onEdit).toHaveBeenCalledWith(mockService);
fireEvent.click(screen.getByTitle('Delete'));
expect(onDelete).toHaveBeenCalledWith(mockService);
});
});

View File

@@ -9,7 +9,7 @@
*/
import React, { useMemo, useState } from 'react';
import { BlockedDate, BlockType } from '../../types';
import { BlockedDate, BlockType, BlockPurpose } from '../../types';
interface TimeBlockCalendarOverlayProps {
blockedDates: BlockedDate[];
@@ -126,61 +126,46 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
return overlays;
}, [relevantBlocks, days, dayWidth, pixelsPerMinute, zoomLevel, startHour]);
const getBlockStyle = (blockType: BlockType, isBusinessLevel: boolean): React.CSSProperties => {
const getBlockStyle = (blockType: BlockType, purpose: BlockPurpose, isBusinessLevel: boolean): React.CSSProperties => {
const baseStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
height: '100%',
pointerEvents: 'auto',
cursor: 'default',
zIndex: 5, // Ensure overlays are visible above grid lines
};
// Business-level blocks (including business hours): Simple gray background
// No fancy styling - just indicates "not available for booking"
if (isBusinessLevel) {
// Business blocks: Red (hard) / Amber (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
background: `repeating-linear-gradient(
-45deg,
rgba(239, 68, 68, 0.3),
rgba(239, 68, 68, 0.3) 5px,
rgba(239, 68, 68, 0.5) 5px,
rgba(239, 68, 68, 0.5) 10px
)`,
borderTop: '2px solid rgba(239, 68, 68, 0.7)',
borderBottom: '2px solid rgba(239, 68, 68, 0.7)',
};
} else {
return {
...baseStyle,
background: 'rgba(251, 191, 36, 0.2)',
borderTop: '2px dashed rgba(251, 191, 36, 0.8)',
borderBottom: '2px dashed rgba(251, 191, 36, 0.8)',
};
}
return {
...baseStyle,
background: 'rgba(107, 114, 128, 0.25)', // Gray-500 at 25% opacity (more visible)
};
}
// Resource-level blocks: Purple (hard) / Cyan (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
background: `repeating-linear-gradient(
-45deg,
rgba(147, 51, 234, 0.25),
rgba(147, 51, 234, 0.25) 5px,
rgba(147, 51, 234, 0.4) 5px,
rgba(147, 51, 234, 0.4) 10px
)`,
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
};
} else {
// Resource blocks: Purple (hard) / Cyan (soft)
if (blockType === 'HARD') {
return {
...baseStyle,
background: `repeating-linear-gradient(
-45deg,
rgba(147, 51, 234, 0.25),
rgba(147, 51, 234, 0.25) 5px,
rgba(147, 51, 234, 0.4) 5px,
rgba(147, 51, 234, 0.4) 10px
)`,
borderTop: '2px solid rgba(147, 51, 234, 0.7)',
borderBottom: '2px solid rgba(147, 51, 234, 0.7)',
};
} else {
return {
...baseStyle,
background: 'rgba(6, 182, 212, 0.15)',
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
};
}
return {
...baseStyle,
background: 'rgba(6, 182, 212, 0.15)',
borderTop: '2px dashed rgba(6, 182, 212, 0.7)',
borderBottom: '2px dashed rgba(6, 182, 212, 0.7)',
};
}
};
@@ -208,7 +193,7 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
<>
{blockOverlays.map((overlay, index) => {
const isBusinessLevel = overlay.block.resource_id === null;
const style = getBlockStyle(overlay.block.block_type, isBusinessLevel);
const style = getBlockStyle(overlay.block.block_type, overlay.block.purpose, isBusinessLevel);
return (
<div
@@ -224,14 +209,12 @@ const TimeBlockCalendarOverlay: React.FC<TimeBlockCalendarOverlayProps> = ({
onMouseLeave={handleMouseLeave}
onClick={() => onDayClick?.(days[overlay.dayIndex])}
>
{/* Block level indicator */}
<div className={`absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide ${
isBusinessLevel
? 'bg-red-600'
: 'bg-purple-600'
}`}>
{isBusinessLevel ? 'B' : 'R'}
</div>
{/* Only show badge for resource-level blocks */}
{!isBusinessLevel && (
<div className="absolute top-1 left-1 px-1.5 py-0.5 text-white text-[10px] font-bold rounded shadow-sm uppercase tracking-wide bg-purple-600">
R
</div>
)}
</div>
);
})}

View File

@@ -31,6 +31,7 @@ import {
CalendarDays,
CalendarRange,
Loader2,
MapPin,
} from 'lucide-react';
import Portal from '../Portal';
import {
@@ -40,8 +41,11 @@ import {
Holiday,
Resource,
TimeBlockListItem,
Location,
} from '../../types';
import { formatLocalDate } from '../../utils/dateUtils';
import { LocationSelector, useShouldShowLocationSelector, useAutoSelectLocation } from '../LocationSelector';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
// Preset block types
const PRESETS = [
@@ -155,6 +159,7 @@ interface TimeBlockCreatorModalProps {
editingBlock?: TimeBlockListItem | null;
holidays: Holiday[];
resources: Resource[];
locations?: Location[];
isResourceLevel?: boolean;
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
staffMode?: boolean;
@@ -162,6 +167,9 @@ interface TimeBlockCreatorModalProps {
staffResourceId?: string | number | null;
}
// Block level types for the three-tier system
type BlockLevel = 'business' | 'location' | 'resource';
type Step = 'preset' | 'details' | 'schedule' | 'review';
const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
@@ -172,6 +180,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
editingBlock,
holidays,
resources,
locations = [],
isResourceLevel: initialIsResourceLevel = false,
staffMode = false,
staffResourceId = null,
@@ -181,6 +190,18 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [isResourceLevel, setIsResourceLevel] = useState(initialIsResourceLevel);
// Multi-location support
const { canUse } = usePlanFeatures();
const hasMultiLocation = canUse('multi_location');
const showLocationSelector = useShouldShowLocationSelector();
const [blockLevel, setBlockLevel] = useState<BlockLevel>(
initialIsResourceLevel ? 'resource' : 'business'
);
const [locationId, setLocationId] = useState<number | null>(null);
// Auto-select location when only one exists
useAutoSelectLocation(locationId, setLocationId);
// Form state
const [title, setTitle] = useState(editingBlock?.title || '');
const [description, setDescription] = useState(editingBlock?.description || '');
@@ -233,7 +254,21 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setStartTime(editingBlock.start_time || '09:00');
setEndTime(editingBlock.end_time || '17:00');
setResourceId(editingBlock.resource || null);
setIsResourceLevel(!!editingBlock.resource); // Set level based on whether block has a resource
setLocationId(editingBlock.location ?? null);
// Determine block level based on existing data
if (editingBlock.is_business_wide) {
setBlockLevel('business');
setIsResourceLevel(false);
} else if (editingBlock.location && !editingBlock.resource) {
setBlockLevel('location');
setIsResourceLevel(false);
} else if (editingBlock.resource) {
setBlockLevel('resource');
setIsResourceLevel(true);
} else {
setBlockLevel('business');
setIsResourceLevel(false);
}
// Parse dates if available
if (editingBlock.start_date) {
const startDate = new Date(editingBlock.start_date);
@@ -288,8 +323,10 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setHolidayCodes([]);
setRecurrenceStart('');
setRecurrenceEnd('');
setLocationId(null);
// In staff mode, always resource-level
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
setBlockLevel(staffMode ? 'resource' : (initialIsResourceLevel ? 'resource' : 'business'));
}
}
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
@@ -381,12 +418,37 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
// In staff mode, always use the staff's resource ID
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
// Determine location and resource based on block level
let effectiveLocation: number | null = null;
let effectiveResource: string | number | null = null;
let isBusinessWide = false;
switch (blockLevel) {
case 'business':
isBusinessWide = true;
effectiveLocation = null;
effectiveResource = null;
break;
case 'location':
isBusinessWide = false;
effectiveLocation = locationId;
effectiveResource = null;
break;
case 'resource':
isBusinessWide = false;
effectiveLocation = locationId; // Resource blocks can optionally have a location
effectiveResource = effectiveResourceId;
break;
}
const baseData: any = {
description: description || undefined,
block_type: blockType,
recurrence_type: recurrenceType,
all_day: allDay,
resource: isResourceLevel ? effectiveResourceId : null,
resource: effectiveResource,
location: effectiveLocation,
is_business_wide: isBusinessWide,
};
if (!allDay) {
@@ -441,6 +503,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
if (!title.trim()) return false;
// In staff mode, resource is auto-selected; otherwise check if selected
if (isResourceLevel && !staffMode && !resourceId) return false;
// Location is required when blockLevel is 'location'
if (blockLevel === 'location' && !locationId) return false;
return true;
case 'schedule':
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
@@ -577,48 +641,87 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Level
</label>
<div className="grid grid-cols-2 gap-4">
<div className={`grid gap-4 ${showLocationSelector ? 'grid-cols-3' : 'grid-cols-2'}`}>
{/* Business-wide option */}
<button
type="button"
onClick={() => {
setBlockLevel('business');
setIsResourceLevel(false);
setResourceId(null);
setLocationId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
!isResourceLevel
blockLevel === 'business'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
<div className={`p-2 rounded-lg ${blockLevel === 'business' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Building2 size={20} className={blockLevel === 'business' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
<p className={`font-semibold ${blockLevel === 'business' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Business-wide
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects all resources
{showLocationSelector ? 'All locations & resources' : 'Affects all resources'}
</p>
</div>
</div>
</button>
{/* Location-wide option - only show when multi-location is enabled */}
{showLocationSelector && (
<button
type="button"
onClick={() => {
setBlockLevel('location');
setIsResourceLevel(false);
setResourceId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
blockLevel === 'location'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${blockLevel === 'location' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<MapPin size={20} className={blockLevel === 'location' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${blockLevel === 'location' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Specific Location
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
All resources at one location
</p>
</div>
</div>
</button>
)}
{/* Resource-specific option */}
<button
type="button"
onClick={() => setIsResourceLevel(true)}
onClick={() => {
setBlockLevel('resource');
setIsResourceLevel(true);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
isResourceLevel
blockLevel === 'resource'
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
<div className={`p-2 rounded-lg ${blockLevel === 'resource' ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<User size={20} className={blockLevel === 'resource' ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
<p className={`font-semibold ${blockLevel === 'resource' ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Specific Resource
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
@@ -628,6 +731,18 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
</div>
</button>
</div>
{/* Location Selector - show when location-level is selected */}
{blockLevel === 'location' && showLocationSelector && (
<div className="mt-4">
<LocationSelector
value={locationId}
onChange={setLocationId}
label="Location"
required
/>
</div>
)}
</div>
)}
@@ -661,20 +776,32 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
{isResourceLevel && !staffMode && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource
</label>
<select
value={resourceId || ''}
onChange={(e) => setResourceId(e.target.value || null)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
>
<option value="">Select a resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
<div className="space-y-4">
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource
</label>
<select
value={resourceId || ''}
onChange={(e) => setResourceId(e.target.value || null)}
className="w-full px-4 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
>
<option value="">Select a resource...</option>
{resources.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
{/* Optional location for resource-level blocks when multi-location is enabled */}
{showLocationSelector && (
<LocationSelector
value={locationId}
onChange={setLocationId}
label="Location (optional)"
hint="Optionally limit this block to a specific location"
/>
)}
</div>
)}
@@ -1207,6 +1334,40 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
)}
</dd>
</div>
{/* Block Level - show when multi-location is enabled or not in staff mode */}
{!staffMode && (
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Applies To</dt>
<dd className="font-medium text-gray-900 dark:text-white">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-sm ${
blockLevel === 'business'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: blockLevel === 'location'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}`}>
{blockLevel === 'business' && <Building2 size={14} />}
{blockLevel === 'location' && <MapPin size={14} />}
{blockLevel === 'resource' && <User size={14} />}
{blockLevel === 'business' && 'Business-wide'}
{blockLevel === 'location' && 'Specific Location'}
{blockLevel === 'resource' && 'Specific Resource'}
</span>
</dd>
</div>
)}
{/* Location - show when location is selected */}
{(blockLevel === 'location' || (blockLevel === 'resource' && locationId)) && locationId && (
<div className="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt className="text-gray-500 dark:text-gray-400">Location</dt>
<dd className="font-medium text-gray-900 dark:text-white">
{locations.find(l => l.id === locationId)?.name || `Location ${locationId}`}
</dd>
</div>
)}
{/* Resource - show for resource-level blocks */}
{isResourceLevel && (resourceId || staffResourceId) && (
<div className="flex justify-between py-2">
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
type AlertVariant = 'error' | 'success' | 'warning' | 'info';
interface AlertProps {
variant: AlertVariant;
message: string | React.ReactNode;
title?: string;
onDismiss?: () => void;
className?: string;
/** Compact mode for inline alerts */
compact?: boolean;
}
const variantConfig: Record<AlertVariant, {
icon: React.ReactNode;
containerClass: string;
textClass: string;
titleClass: string;
}> = {
error: {
icon: <AlertCircle size={20} />,
containerClass: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
textClass: 'text-red-800 dark:text-red-200',
titleClass: 'text-red-900 dark:text-red-100',
},
success: {
icon: <CheckCircle size={20} />,
containerClass: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
textClass: 'text-green-800 dark:text-green-200',
titleClass: 'text-green-900 dark:text-green-100',
},
warning: {
icon: <AlertTriangle size={20} />,
containerClass: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800',
textClass: 'text-amber-800 dark:text-amber-200',
titleClass: 'text-amber-900 dark:text-amber-100',
},
info: {
icon: <Info size={20} />,
containerClass: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
textClass: 'text-blue-800 dark:text-blue-200',
titleClass: 'text-blue-900 dark:text-blue-100',
},
};
export const Alert: React.FC<AlertProps> = ({
variant,
message,
title,
onDismiss,
className = '',
compact = false,
}) => {
const config = variantConfig[variant];
return (
<div
className={`${compact ? 'p-2' : 'p-3'} border rounded-lg ${config.containerClass} ${className}`}
role="alert"
>
<div className="flex items-start gap-3">
<span className={`flex-shrink-0 ${config.textClass}`}>{config.icon}</span>
<div className="flex-1 min-w-0">
{title && (
<p className={`font-medium ${config.titleClass} ${compact ? 'text-sm' : ''}`}>
{title}
</p>
)}
<div className={`${compact ? 'text-xs' : 'text-sm'} ${config.textClass} ${title ? 'mt-1' : ''}`}>
{typeof message === 'string' ? <p>{message}</p> : message}
</div>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className={`flex-shrink-0 p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${config.textClass}`}
aria-label="Dismiss"
>
<X size={16} />
</button>
)}
</div>
</div>
);
};
/** Convenience components */
export const ErrorMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="error" {...props} />
);
export const SuccessMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="success" {...props} />
);
export const WarningMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="warning" {...props} />
);
export const InfoMessage: React.FC<Omit<AlertProps, 'variant'>> = (props) => (
<Alert variant="info" {...props} />
);
export default Alert;

View File

@@ -0,0 +1,61 @@
import React from 'react';
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BadgeSize = 'sm' | 'md' | 'lg';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
size?: BadgeSize;
/** Rounded pill style */
pill?: boolean;
/** Dot indicator before text */
dot?: boolean;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200',
primary: 'bg-brand-100 dark:bg-brand-900/30 text-brand-800 dark:text-brand-200',
success: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200',
warning: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200',
danger: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200',
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200',
};
const dotColors: Record<BadgeVariant, string> = {
default: 'bg-gray-400',
primary: 'bg-brand-500',
success: 'bg-green-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
info: 'bg-blue-500',
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-xs',
lg: 'px-2.5 py-1.5 text-sm',
};
export const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
pill = false,
dot = false,
className = '',
}) => {
const roundedClass = pill ? 'rounded-full' : 'rounded';
return (
<span
className={`inline-flex items-center gap-1.5 font-medium ${roundedClass} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`} />}
{children}
</span>
);
};
export default Badge;

View File

@@ -0,0 +1,108 @@
import React, { forwardRef } from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
loadingText?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-600 hover:bg-brand-700 text-white border-transparent',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white border-transparent',
outline: 'bg-transparent hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600',
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 border-transparent',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
success: 'bg-green-600 hover:bg-green-700 text-white border-transparent',
warning: 'bg-amber-600 hover:bg-amber-700 text-white border-transparent',
};
const sizeClasses: Record<ButtonSize, string> = {
xs: 'px-2 py-1 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const iconSizes: Record<ButtonSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
isLoading = false,
loadingText,
leftIcon,
rightIcon,
fullWidth = false,
disabled,
children,
className = '',
...props
},
ref
) => {
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center gap-2
font-medium rounded-lg border
transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]}
${sizeClasses[size]}
${fullWidth ? 'w-full' : ''}
${className}
`}
{...props}
>
{isLoading ? (
<>
<Loader2 className={`animate-spin ${iconSizes[size]}`} />
{loadingText || children}
</>
) : (
<>
{leftIcon && <span className={iconSizes[size]}>{leftIcon}</span>}
{children}
{rightIcon && <span className={iconSizes[size]}>{rightIcon}</span>}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
/** Convenience component for submit buttons */
export const SubmitButton: React.FC<Omit<ButtonProps, 'type'> & { submitText?: string }> = ({
isLoading,
submitText = 'Save',
loadingText = 'Saving...',
children,
...props
}) => (
<Button type="submit" isLoading={isLoading} loadingText={loadingText} {...props}>
{children || submitText}
</Button>
);
export default Button;

View File

@@ -0,0 +1,88 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
/** Card padding */
padding?: 'none' | 'sm' | 'md' | 'lg';
/** Show border */
bordered?: boolean;
/** Hover effect */
hoverable?: boolean;
/** Click handler */
onClick?: () => void;
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
/** Action buttons for the header */
actions?: React.ReactNode;
}
interface CardBodyProps {
children: React.ReactNode;
className?: string;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
export const Card: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
bordered = true,
hoverable = false,
onClick,
}) => {
const baseClasses = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm';
const borderClass = bordered ? 'border border-gray-200 dark:border-gray-700' : '';
const hoverClass = hoverable
? 'hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600 transition-all cursor-pointer'
: '';
const paddingClass = paddingClasses[padding];
return (
<div
className={`${baseClasses} ${borderClass} ${hoverClass} ${paddingClass} ${className}`}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
{children}
</div>
);
};
export const CardHeader: React.FC<CardHeaderProps> = ({
children,
className = '',
actions,
}) => (
<div className={`flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700 ${className}`}>
<div className="font-semibold text-gray-900 dark:text-white">{children}</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
export const CardBody: React.FC<CardBodyProps> = ({ children, className = '' }) => (
<div className={`py-4 ${className}`}>{children}</div>
);
export const CardFooter: React.FC<CardFooterProps> = ({ children, className = '' }) => (
<div className={`pt-4 border-t border-gray-200 dark:border-gray-700 ${className}`}>
{children}
</div>
);
export default Card;

View File

@@ -0,0 +1,166 @@
import React, { useState, useEffect, useRef } from 'react';
interface CurrencyInputProps {
value: number; // Value in cents (integer)
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
className?: string;
min?: number;
max?: number;
}
/**
* Currency input where digits represent cents.
* Only accepts integer input (0-9), no decimal points.
* Allows normal text selection and editing.
*
* Examples:
* - Type "5" → $0.05
* - Type "50" → $0.50
* - Type "500" → $5.00
* - Type "1234" → $12.34
*/
const CurrencyInput: React.FC<CurrencyInputProps> = ({
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
className = '',
min,
max,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [displayValue, setDisplayValue] = useState('');
// Format cents as dollars string (e.g., 1234 → "$12.34")
const formatCentsAsDollars = (cents: number): string => {
if (cents === 0) return '';
const dollars = cents / 100;
return `$${dollars.toFixed(2)}`;
};
// Extract just the digits from a string
const extractDigits = (str: string): string => {
return str.replace(/\D/g, '');
};
// Sync display value when external value changes
useEffect(() => {
setDisplayValue(formatCentsAsDollars(value));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
// Extract only digits
const digits = extractDigits(input);
// Convert to cents (the digits ARE the cents value)
let cents = digits ? parseInt(digits, 10) : 0;
// Enforce max if specified
if (max !== undefined && cents > max) {
cents = max;
}
onChange(cents);
// Update display immediately with formatted value
setDisplayValue(formatCentsAsDollars(cents));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Allow: navigation, selection, delete, backspace, tab, escape, enter
const allowedKeys = [
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
'Home', 'End'
];
if (allowedKeys.includes(e.key)) {
return; // Let these through
}
// Allow Ctrl/Cmd + A, C, V, X (select all, copy, paste, cut)
if ((e.ctrlKey || e.metaKey) && ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase())) {
return;
}
// Only allow digits 0-9
if (!/^[0-9]$/.test(e.key)) {
e.preventDefault();
}
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
// Select all text for easy replacement
setTimeout(() => {
e.target.select();
}, 0);
};
const handleBlur = () => {
// Extract digits and reparse to enforce constraints
const digits = extractDigits(displayValue);
let cents = digits ? parseInt(digits, 10) : 0;
// Enforce min on blur if specified
if (min !== undefined && cents < min && cents > 0) {
cents = min;
onChange(cents);
}
// Enforce max on blur if specified
if (max !== undefined && cents > max) {
cents = max;
onChange(cents);
}
// Reformat display
setDisplayValue(formatCentsAsDollars(cents));
};
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const digits = extractDigits(pastedText);
if (digits) {
let cents = parseInt(digits, 10);
if (max !== undefined && cents > max) {
cents = max;
}
onChange(cents);
setDisplayValue(formatCentsAsDollars(cents));
}
};
return (
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={displayValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
disabled={disabled}
required={required}
placeholder={placeholder}
className={className}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
);
};
export default CurrencyInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Inbox } from 'lucide-react';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
className = '',
}) => {
return (
<div className={`text-center py-12 px-4 ${className}`}>
<div className="flex justify-center mb-4">
{icon || <Inbox className="h-12 w-12 text-gray-400" />}
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto mb-4">
{description}
</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import CurrencyInput from './CurrencyInput';
interface FormCurrencyInputProps {
label?: string;
error?: string;
hint?: string;
value: number;
onChange: (cents: number) => void;
disabled?: boolean;
required?: boolean;
placeholder?: string;
min?: number;
max?: number;
/** Container class name */
containerClassName?: string;
/** Input class name */
className?: string;
}
/**
* Form wrapper for CurrencyInput that adds label, error, and hint support.
* Uses the ATM-style currency input where digits are entered as cents.
*/
export const FormCurrencyInput: React.FC<FormCurrencyInputProps> = ({
label,
error,
hint,
value,
onChange,
disabled = false,
required = false,
placeholder = '$0.00',
min,
max,
containerClassName = '',
className = '',
}) => {
const baseInputClasses =
'w-full px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
return (
<div className={containerClassName}>
{label && (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<CurrencyInput
value={value}
onChange={onChange}
disabled={disabled}
required={required}
placeholder={placeholder}
min={min}
max={max}
className={`${baseInputClasses} ${stateClasses} ${disabledClasses} ${className}`}
/>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
};
export default FormCurrencyInput;

View File

@@ -0,0 +1,104 @@
import React, { forwardRef } from 'react';
interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
/** Size variant */
inputSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Icon to display on the left */
leftIcon?: React.ReactNode;
/** Icon to display on the right */
rightIcon?: React.ReactNode;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(
{
label,
error,
hint,
inputSize = 'md',
fullWidth = true,
leftIcon,
rightIcon,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const inputId = id || props.name || `input-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
{leftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{leftIcon}
</div>
)}
<input
ref={ref}
id={inputId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[inputSize]} ${widthClass} ${leftIcon ? 'pl-10' : ''} ${rightIcon ? 'pr-10' : ''} ${className}`}
{...props}
/>
{rightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormInput.displayName = 'FormInput';
export default FormInput;

View File

@@ -0,0 +1,115 @@
import React, { forwardRef } from 'react';
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
}
interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
options: SelectOption[];
/** Size variant */
selectSize?: 'sm' | 'md' | 'lg';
/** Full width */
fullWidth?: boolean;
/** Placeholder option */
placeholder?: string;
/** Container class name */
containerClassName?: string;
}
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2',
lg: 'px-4 py-3 text-lg',
};
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
(
{
label,
error,
hint,
options,
selectSize = 'md',
fullWidth = true,
placeholder,
containerClassName = '',
className = '',
id,
...props
},
ref
) => {
const selectId = id || props.name || `select-${Math.random().toString(36).substr(2, 9)}`;
const baseClasses =
'border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors appearance-none cursor-pointer';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className={`relative ${widthClass}`}>
<select
ref={ref}
id={selectId}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${sizeClasses[selectSize]} ${widthClass} pr-10 ${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
);
}
);
FormSelect.displayName = 'FormSelect';
export default FormSelect;

View File

@@ -0,0 +1,94 @@
import React, { forwardRef } from 'react';
interface FormTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
hint?: string;
/** Full width */
fullWidth?: boolean;
/** Container class name */
containerClassName?: string;
/** Show character count */
showCharCount?: boolean;
/** Max characters for count display */
maxChars?: number;
}
export const FormTextarea = forwardRef<HTMLTextAreaElement, FormTextareaProps>(
(
{
label,
error,
hint,
fullWidth = true,
containerClassName = '',
className = '',
id,
showCharCount = false,
maxChars,
value,
...props
},
ref
) => {
const textareaId = id || props.name || `textarea-${Math.random().toString(36).substr(2, 9)}`;
const charCount = typeof value === 'string' ? value.length : 0;
const baseClasses =
'px-3 py-2 border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors resize-y';
const stateClasses = error
? 'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600';
const disabledClasses = props.disabled
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-60'
: '';
const widthClass = fullWidth ? 'w-full' : '';
return (
<div className={`${containerClassName}`}>
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<textarea
ref={ref}
id={textareaId}
value={value}
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${widthClass} ${className}`}
{...props}
/>
<div className="flex justify-between items-center mt-1">
<div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{hint && !error && (
<p className="text-sm text-gray-500 dark:text-gray-400">{hint}</p>
)}
</div>
{showCharCount && (
<p className={`text-sm ${maxChars && charCount > maxChars ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
{charCount}{maxChars ? `/${maxChars}` : ''}
</p>
)}
</div>
</div>
);
}
);
FormTextarea.displayName = 'FormTextarea';
export default FormTextarea;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
/** Size of the spinner */
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/** Color of the spinner */
color?: 'default' | 'white' | 'brand' | 'blue';
/** Optional label to display below spinner */
label?: string;
/** Center spinner in container */
centered?: boolean;
/** Additional class name */
className?: string;
}
const sizeClasses = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-12 w-12',
};
const colorClasses = {
default: 'text-gray-500 dark:text-gray-400',
white: 'text-white',
brand: 'text-brand-600 dark:text-brand-400',
blue: 'text-blue-600 dark:text-blue-400',
};
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
color = 'default',
label,
centered = false,
className = '',
}) => {
const spinner = (
<div className={`flex flex-col items-center gap-2 ${className}`}>
<Loader2 className={`animate-spin ${sizeClasses[size]} ${colorClasses[color]}`} />
{label && (
<span className={`text-sm ${colorClasses[color]}`}>{label}</span>
)}
</div>
);
if (centered) {
return (
<div className="flex items-center justify-center py-12">
{spinner}
</div>
);
}
return spinner;
};
/** Full page loading state */
export const PageLoading: React.FC<{ label?: string }> = ({ label = 'Loading...' }) => (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" color="brand" label={label} />
</div>
);
/** Inline loading indicator */
export const InlineLoading: React.FC<{ label?: string }> = ({ label }) => (
<span className="inline-flex items-center gap-2">
<LoadingSpinner size="sm" />
{label && <span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>}
</span>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useCallback } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string | React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: ModalSize;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
closeOnEscape?: boolean;
className?: string;
contentClassName?: string;
/** If true, prevents body scroll when modal is open */
preventScroll?: boolean;
}
const sizeClasses: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
full: 'max-w-full mx-4',
};
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
size = 'md',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
className = '',
contentClassName = '',
preventScroll = true,
}) => {
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (closeOnEscape && e.key === 'Escape') {
onClose();
}
},
[closeOnEscape, onClose]
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = 'hidden';
}
}
return () => {
document.removeEventListener('keydown', handleEscape);
if (preventScroll) {
document.body.style.overflow = '';
}
};
}, [isOpen, handleEscape, preventScroll]);
if (!isOpen) return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
const modalContent = (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50 backdrop-blur-sm"
onClick={handleOverlayClick}
>
<div
className={`bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col ${className}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors ml-auto"
aria-label="Close modal"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
)}
</div>
)}
{/* Content */}
<div className={`flex-1 overflow-y-auto p-6 ${contentClassName}`}>
{children}
</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
{footer}
</div>
)}
</div>
</div>
);
// Use portal to render modal at document body level
return createPortal(modalContent, document.body);
};
export default Modal;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
interface ModalFooterProps {
onCancel?: () => void;
onSubmit?: () => void;
onBack?: () => void;
submitText?: string;
cancelText?: string;
backText?: string;
isLoading?: boolean;
isDisabled?: boolean;
showBackButton?: boolean;
submitVariant?: ButtonVariant;
/** Custom content to render instead of default buttons */
children?: React.ReactNode;
/** Additional class names */
className?: string;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
success: 'bg-green-600 hover:bg-green-700 text-white',
warning: 'bg-amber-600 hover:bg-amber-700 text-white',
};
export const ModalFooter: React.FC<ModalFooterProps> = ({
onCancel,
onSubmit,
onBack,
submitText = 'Save',
cancelText = 'Cancel',
backText = 'Back',
isLoading = false,
isDisabled = false,
showBackButton = false,
submitVariant = 'primary',
children,
className = '',
}) => {
if (children) {
return <div className={`flex items-center gap-3 ${className}`}>{children}</div>;
}
return (
<div className={`flex items-center gap-3 ${className}`}>
{showBackButton && onBack && (
<button
onClick={onBack}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
{backText}
</button>
)}
<div className="flex-1" />
{onCancel && (
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{cancelText}
</button>
)}
{onSubmit && (
<button
onClick={onSubmit}
disabled={isLoading || isDisabled}
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${variantClasses[submitVariant]}`}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{submitText}
</button>
)}
</div>
);
};
export default ModalFooter;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Check } from 'lucide-react';
interface Step {
id: string | number;
label: string;
description?: string;
}
interface StepIndicatorProps {
steps: Step[];
currentStep: number;
/** Color for completed/active steps */
color?: 'blue' | 'brand' | 'green' | 'purple';
/** Show connector lines between steps */
showConnectors?: boolean;
/** Additional class name */
className?: string;
}
const colorClasses = {
blue: {
active: 'bg-blue-600 text-white',
completed: 'bg-blue-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-blue-600 dark:text-blue-400',
textPending: 'text-gray-400',
connector: 'bg-blue-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
brand: {
active: 'bg-brand-600 text-white',
completed: 'bg-brand-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-brand-600 dark:text-brand-400',
textPending: 'text-gray-400',
connector: 'bg-brand-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
green: {
active: 'bg-green-600 text-white',
completed: 'bg-green-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-green-600 dark:text-green-400',
textPending: 'text-gray-400',
connector: 'bg-green-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
purple: {
active: 'bg-purple-600 text-white',
completed: 'bg-purple-600 text-white',
pending: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
textActive: 'text-purple-600 dark:text-purple-400',
textPending: 'text-gray-400',
connector: 'bg-purple-600',
connectorPending: 'bg-gray-200 dark:bg-gray-700',
},
};
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
color = 'blue',
showConnectors = true,
className = '',
}) => {
const colors = colorClasses[color];
return (
<div className={`flex items-center justify-center ${className}`}>
{steps.map((step, index) => {
const stepNumber = index + 1;
const isCompleted = stepNumber < currentStep;
const isActive = stepNumber === currentStep;
const isPending = stepNumber > currentStep;
return (
<React.Fragment key={step.id}>
<div className="flex items-center gap-2">
{/* Step circle */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm transition-colors ${
isCompleted
? colors.completed
: isActive
? colors.active
: colors.pending
}`}
>
{isCompleted ? <Check size={16} /> : stepNumber}
</div>
{/* Step label */}
<span
className={`font-medium text-sm ${
isActive || isCompleted ? colors.textActive : colors.textPending
}`}
>
{step.label}
</span>
</div>
{/* Connector */}
{showConnectors && index < steps.length - 1 && (
<div
className={`w-16 h-0.5 mx-4 ${
stepNumber < currentStep ? colors.connector : colors.connectorPending
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,150 @@
import React from 'react';
interface Tab {
id: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
disabled?: boolean;
}
interface TabGroupProps {
tabs: Tab[];
activeTab: string;
onChange: (tabId: string) => void;
/** Visual variant */
variant?: 'default' | 'pills' | 'underline';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Full width tabs */
fullWidth?: boolean;
/** Additional class name */
className?: string;
/** Color for active state */
activeColor?: 'blue' | 'purple' | 'green' | 'brand';
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
const activeColorClasses = {
blue: {
active: 'bg-blue-600 text-white',
pills: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
underline: 'border-blue-600 text-blue-600 dark:text-blue-400',
},
purple: {
active: 'bg-purple-600 text-white',
pills: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
underline: 'border-purple-600 text-purple-600 dark:text-purple-400',
},
green: {
active: 'bg-green-600 text-white',
pills: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
underline: 'border-green-600 text-green-600 dark:text-green-400',
},
brand: {
active: 'bg-brand-600 text-white',
pills: 'bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300',
underline: 'border-brand-600 text-brand-600 dark:text-brand-400',
},
};
export const TabGroup: React.FC<TabGroupProps> = ({
tabs,
activeTab,
onChange,
variant = 'default',
size = 'md',
fullWidth = true,
className = '',
activeColor = 'blue',
}) => {
const colorClasses = activeColorClasses[activeColor];
if (variant === 'underline') {
return (
<div className={`flex border-b border-gray-200 dark:border-gray-700 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium border-b-2 -mb-px transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.underline
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
if (variant === 'pills') {
return (
<div className={`flex gap-2 ${className}`}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium rounded-full transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.pills
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
}
// Default variant - segmented control style
return (
<div
className={`flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onChange(tab.id)}
disabled={tab.disabled}
className={`${sizeClasses[size]} font-medium transition-colors ${fullWidth ? 'flex-1' : ''} ${
isActive
? colorClasses.active
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
} ${tab.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className="flex items-center justify-center gap-2">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
);
};
export default TabGroup;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import Badge from './Badge';
export const UnfinishedBadge: React.FC = () => {
return (
<Badge variant="warning" size="sm" pill>
WIP
</Badge>
);
};
export default UnfinishedBadge;

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Alert, ErrorMessage, SuccessMessage, WarningMessage, InfoMessage } from '../Alert';
describe('Alert', () => {
it('renders message', () => {
render(<Alert variant="info" message="Test message" />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('renders title when provided', () => {
render(<Alert variant="info" message="Message" title="Title" />);
expect(screen.getByText('Title')).toBeInTheDocument();
});
it('renders message as ReactNode', () => {
render(
<Alert
variant="info"
message={<span data-testid="custom">Custom content</span>}
/>
);
expect(screen.getByTestId('custom')).toBeInTheDocument();
});
it('has alert role', () => {
render(<Alert variant="info" message="Test" />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('renders error variant', () => {
render(<Alert variant="error" message="Error" />);
expect(screen.getByRole('alert')).toHaveClass('bg-red-50');
});
it('renders success variant', () => {
render(<Alert variant="success" message="Success" />);
expect(screen.getByRole('alert')).toHaveClass('bg-green-50');
});
it('renders warning variant', () => {
render(<Alert variant="warning" message="Warning" />);
expect(screen.getByRole('alert')).toHaveClass('bg-amber-50');
});
it('renders info variant', () => {
render(<Alert variant="info" message="Info" />);
expect(screen.getByRole('alert')).toHaveClass('bg-blue-50');
});
it('shows dismiss button when onDismiss is provided', () => {
const handleDismiss = vi.fn();
render(<Alert variant="info" message="Test" onDismiss={handleDismiss} />);
expect(screen.getByLabelText('Dismiss')).toBeInTheDocument();
});
it('calls onDismiss when dismiss button clicked', () => {
const handleDismiss = vi.fn();
render(<Alert variant="info" message="Test" onDismiss={handleDismiss} />);
fireEvent.click(screen.getByLabelText('Dismiss'));
expect(handleDismiss).toHaveBeenCalled();
});
it('does not show dismiss button without onDismiss', () => {
render(<Alert variant="info" message="Test" />);
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
});
it('applies custom className', () => {
render(<Alert variant="info" message="Test" className="custom-class" />);
expect(screen.getByRole('alert')).toHaveClass('custom-class');
});
it('applies compact style', () => {
render(<Alert variant="info" message="Test" compact />);
expect(screen.getByRole('alert')).toHaveClass('p-2');
});
it('applies regular padding without compact', () => {
render(<Alert variant="info" message="Test" />);
expect(screen.getByRole('alert')).toHaveClass('p-3');
});
});
describe('Convenience components', () => {
it('ErrorMessage renders error variant', () => {
render(<ErrorMessage message="Error" />);
expect(screen.getByRole('alert')).toHaveClass('bg-red-50');
});
it('SuccessMessage renders success variant', () => {
render(<SuccessMessage message="Success" />);
expect(screen.getByRole('alert')).toHaveClass('bg-green-50');
});
it('WarningMessage renders warning variant', () => {
render(<WarningMessage message="Warning" />);
expect(screen.getByRole('alert')).toHaveClass('bg-amber-50');
});
it('InfoMessage renders info variant', () => {
render(<InfoMessage message="Info" />);
expect(screen.getByRole('alert')).toHaveClass('bg-blue-50');
});
});

View File

@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Badge } from '../Badge';
describe('Badge', () => {
it('renders children', () => {
render(<Badge>Test</Badge>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('renders default variant', () => {
render(<Badge>Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-gray-100');
});
it('renders primary variant', () => {
render(<Badge variant="primary">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-brand-100');
});
it('renders success variant', () => {
render(<Badge variant="success">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-green-100');
});
it('renders warning variant', () => {
render(<Badge variant="warning">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-amber-100');
});
it('renders danger variant', () => {
render(<Badge variant="danger">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-red-100');
});
it('renders info variant', () => {
render(<Badge variant="info">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('bg-blue-100');
});
it('applies small size', () => {
render(<Badge size="sm">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs');
});
it('applies medium size', () => {
render(<Badge size="md">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('text-xs');
});
it('applies large size', () => {
render(<Badge size="lg">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('text-sm');
});
it('applies pill style', () => {
render(<Badge pill>Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('rounded-full');
});
it('applies rounded style by default', () => {
render(<Badge>Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('rounded');
});
it('renders dot indicator', () => {
const { container } = render(<Badge dot>Test</Badge>);
const dot = container.querySelector('.rounded-full');
expect(dot).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Badge className="custom-class">Test</Badge>);
expect(screen.getByText('Test').closest('span')).toHaveClass('custom-class');
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button, SubmitButton } from '../Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('is disabled when loading', () => {
render(<Button isLoading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('shows loading spinner when loading', () => {
render(<Button isLoading>Click me</Button>);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('shows loading text when loading', () => {
render(<Button isLoading loadingText="Loading...">Click me</Button>);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('applies primary variant by default', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-brand-600');
});
it('applies secondary variant', () => {
render(<Button variant="secondary">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-gray-600');
});
it('applies danger variant', () => {
render(<Button variant="danger">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-red-600');
});
it('applies success variant', () => {
render(<Button variant="success">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-green-600');
});
it('applies warning variant', () => {
render(<Button variant="warning">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-amber-600');
});
it('applies outline variant', () => {
render(<Button variant="outline">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
});
it('applies ghost variant', () => {
render(<Button variant="ghost">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
});
it('applies size classes', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('px-3');
});
it('applies full width', () => {
render(<Button fullWidth>Full Width</Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
it('renders left icon', () => {
render(<Button leftIcon={<span data-testid="left-icon">L</span>}>With Icon</Button>);
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
});
it('renders right icon', () => {
render(<Button rightIcon={<span data-testid="right-icon">R</span>}>With Icon</Button>);
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Button className="custom-class">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('custom-class');
});
});
describe('SubmitButton', () => {
it('renders submit text by default', () => {
render(<SubmitButton />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('has type submit', () => {
render(<SubmitButton />);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
it('renders custom submit text', () => {
render(<SubmitButton submitText="Submit" />);
expect(screen.getByText('Submit')).toBeInTheDocument();
});
it('renders children over submitText', () => {
render(<SubmitButton submitText="Submit">Custom</SubmitButton>);
expect(screen.getByText('Custom')).toBeInTheDocument();
});
it('shows loading text when loading', () => {
render(<SubmitButton isLoading />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Card, CardHeader, CardBody, CardFooter } from '../Card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>);
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('applies base card styling', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('bg-white', 'rounded-lg', 'shadow-sm');
});
it('applies bordered style by default', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('border');
});
it('can remove border', () => {
const { container } = render(<Card bordered={false}>Content</Card>);
expect(container.firstChild).not.toHaveClass('border');
});
it('applies medium padding by default', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('p-4');
});
it('applies no padding', () => {
const { container } = render(<Card padding="none">Content</Card>);
expect(container.firstChild).not.toHaveClass('p-3', 'p-4', 'p-6');
});
it('applies small padding', () => {
const { container } = render(<Card padding="sm">Content</Card>);
expect(container.firstChild).toHaveClass('p-3');
});
it('applies large padding', () => {
const { container } = render(<Card padding="lg">Content</Card>);
expect(container.firstChild).toHaveClass('p-6');
});
it('applies hoverable styling when hoverable', () => {
const { container } = render(<Card hoverable>Content</Card>);
expect(container.firstChild).toHaveClass('hover:shadow-md', 'cursor-pointer');
});
it('is not hoverable by default', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).not.toHaveClass('cursor-pointer');
});
it('handles click events', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Content</Card>);
fireEvent.click(screen.getByText('Content'));
expect(handleClick).toHaveBeenCalled();
});
it('has button role when clickable', () => {
render(<Card onClick={() => {}}>Content</Card>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('has tabIndex when clickable', () => {
render(<Card onClick={() => {}}>Content</Card>);
expect(screen.getByRole('button')).toHaveAttribute('tabIndex', '0');
});
it('does not have button role when not clickable', () => {
render(<Card>Content</Card>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Card className="custom-class">Content</Card>);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('CardHeader', () => {
it('renders children', () => {
render(<CardHeader>Header content</CardHeader>);
expect(screen.getByText('Header content')).toBeInTheDocument();
});
it('applies header styling', () => {
const { container } = render(<CardHeader>Header</CardHeader>);
expect(container.firstChild).toHaveClass('flex', 'items-center', 'justify-between');
});
it('applies border bottom', () => {
const { container } = render(<CardHeader>Header</CardHeader>);
expect(container.firstChild).toHaveClass('border-b');
});
it('renders actions when provided', () => {
render(<CardHeader actions={<button>Action</button>}>Header</CardHeader>);
expect(screen.getByText('Action')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<CardHeader className="custom-class">Header</CardHeader>);
expect(container.firstChild).toHaveClass('custom-class');
});
it('applies font styling to header text', () => {
const { container } = render(<CardHeader>Header</CardHeader>);
const headerText = container.querySelector('.font-semibold');
expect(headerText).toBeInTheDocument();
});
});
describe('CardBody', () => {
it('renders children', () => {
render(<CardBody>Body content</CardBody>);
expect(screen.getByText('Body content')).toBeInTheDocument();
});
it('applies body styling', () => {
const { container } = render(<CardBody>Body</CardBody>);
expect(container.firstChild).toHaveClass('py-4');
});
it('applies custom className', () => {
const { container } = render(<CardBody className="custom-class">Body</CardBody>);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('CardFooter', () => {
it('renders children', () => {
render(<CardFooter>Footer content</CardFooter>);
expect(screen.getByText('Footer content')).toBeInTheDocument();
});
it('applies footer styling', () => {
const { container } = render(<CardFooter>Footer</CardFooter>);
expect(container.firstChild).toHaveClass('pt-4', 'border-t');
});
it('applies custom className', () => {
const { container } = render(<CardFooter className="custom-class">Footer</CardFooter>);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('Card composition', () => {
it('renders complete card with all parts', () => {
render(
<Card>
<CardHeader actions={<button>Edit</button>}>Title</CardHeader>
<CardBody>Main content here</CardBody>
<CardFooter>Footer content</CardFooter>
</Card>
);
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Main content here')).toBeInTheDocument();
expect(screen.getByText('Footer content')).toBeInTheDocument();
});
});

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